@devrik-tools/claude-gates 0.4.0 → 0.7.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -1
@@ -0,0 +1,187 @@
1
+ // staged-lint — denies a `git commit` when the files YOU staged fail lint. It never lints the
2
+ // whole repo (that would block you on pre-existing debt in files you never touched); it lints
3
+ // ONLY the staged files — what this commit actually introduces — so a clean commit is never
4
+ // held hostage by someone else's old lint errors, and you can never add NEW lint debt through
5
+ // your own change.
6
+ //
7
+ // justification: no existing gate covers this. lint-commit runs the project's lint script over
8
+ // the WHOLE project and blocks if anything fails — too broad: a repo with pre-existing debt can
9
+ // never commit. This gate scopes the check to the staged set, so it enforces "your change is
10
+ // clean" without demanding "the whole repo is clean".
11
+ //
12
+ // ── How it works ─────────────────────────────────────────────────────────────────────
13
+ // On a real `git commit`, it asks git for the staged files (`git diff --cached --name-only
14
+ // --diff-filter=ACM`), keeps the ones with a lintable extension, and runs the project's lint
15
+ // command over exactly those paths. Non-zero exit → deny with the lint output. No staged
16
+ // lintable file, or no lint command declared → allow (nothing to enforce, never invented).
17
+ //
18
+ // ── What a project can configure (params) ───────────────────────────────────────────
19
+ // lintCommand the command to run; the staged paths are appended as arguments. Default
20
+ // null → autodetect eslint (`npx eslint`) when the project has an eslint
21
+ // config, else allow (never invent a linter).
22
+ // lintExtensions which staged file extensions are linted. Default js/mjs/cjs/ts/tsx/jsx.
23
+ // lintTimeoutMs how long the lint run may take before it counts as a failure.
24
+ // escapeHatch substring in the command that skips the check for one commit. Default
25
+ // '[skip-lint]'.
26
+ //
27
+ // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
28
+ // Not a commit: allow (silent). No staged lintable files, or no linter: allow. The lint run
29
+ // cannot spawn: deny (a commit gate that cannot evaluate must not silently permit). Lint exits
30
+ // non-zero: deny with the tail of its output. Lint exits zero: allow.
31
+
32
+ import { spawnSync } from 'node:child_process';
33
+ import { existsSync } from 'node:fs';
34
+ import { extname, join } from 'node:path';
35
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
36
+
37
+ const GATE_ID = 'staged-lint';
38
+ const CONFIG_KEY = 'blockCommitWithStagedLintErrors';
39
+
40
+ const SHELL_GROUPS = ['shell'];
41
+ const DEFAULT_LINT_TIMEOUT_MS = 60000;
42
+ const OUTPUT_TAIL_LINES = 20;
43
+ const DEFAULT_ESCAPE_HATCH = '[skip-lint]';
44
+ const DEFAULT_LINT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];
45
+ const ESLINT_CONFIG_FILES = [
46
+ 'eslint.config.mjs',
47
+ 'eslint.config.js',
48
+ 'eslint.config.cjs',
49
+ '.eslintrc',
50
+ '.eslintrc.js',
51
+ '.eslintrc.cjs',
52
+ '.eslintrc.json',
53
+ '.eslintrc.yml',
54
+ '.eslintrc.yaml',
55
+ ];
56
+
57
+ // Same git-global-option normalization lint-commit and no-coauthor use, so
58
+ // `git -C /repo -c x=y commit` reduces to `git commit` before the pattern runs.
59
+ const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
60
+ const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
61
+ const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
62
+ String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
63
+ 'i',
64
+ );
65
+ const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/i;
66
+
67
+ function normalizeGitOptions(command) {
68
+ let previous;
69
+ let normalized = command;
70
+ do {
71
+ previous = normalized;
72
+ normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
73
+ } while (normalized !== previous);
74
+ return normalized;
75
+ }
76
+
77
+ function isGitCommit(command) {
78
+ return GIT_COMMIT_PATTERN.test(normalizeGitOptions(command));
79
+ }
80
+
81
+ function commandTextFrom(toolInput) {
82
+ return String(toolInput.CommandLine ?? toolInput.command ?? '');
83
+ }
84
+
85
+ /** The staged files added/copied/modified (not deleted), with an extension in scope. */
86
+ function stagedFilesToLint(cwd, lintExtensions) {
87
+ const result = spawnSync(
88
+ 'git',
89
+ ['diff', '--cached', '--name-only', '--diff-filter=ACM'],
90
+ { cwd, encoding: 'utf8' },
91
+ );
92
+ if (result.status !== 0 || !result.stdout) return [];
93
+ return result.stdout
94
+ .split(/\r?\n/)
95
+ .map((line) => line.trim())
96
+ .filter((line) => line.length > 0)
97
+ .filter((file) => lintExtensions.includes(extname(file).toLowerCase()));
98
+ }
99
+
100
+ /** The lint command to run, or null when none can be determined (never invented). */
101
+ function resolveLintCommand(cwd, lintCommandOverride) {
102
+ if (lintCommandOverride) return lintCommandOverride;
103
+ const hasEslint = ESLINT_CONFIG_FILES.some((name) =>
104
+ existsSync(join(cwd, name)),
105
+ );
106
+ return hasEslint ? 'npx eslint' : null;
107
+ }
108
+
109
+ function tailLines(text, count) {
110
+ const lines = String(text)
111
+ .split(/\r?\n/)
112
+ .filter((line) => line.length > 0);
113
+ return lines.slice(-count).join('\n');
114
+ }
115
+
116
+ /** The lint command with the staged paths appended (quoted), so ONLY they are linted. */
117
+ function lintCommandForFiles(lintCommand, files) {
118
+ const quotedPaths = files.map((file) => `"${file}"`).join(' ');
119
+ return `${lintCommand} ${quotedPaths}`;
120
+ }
121
+
122
+ /** Runs the lint command over the staged files; returns the spawnSync result. */
123
+ function runLintOverStaged(lintCommand, files, cwd, timeoutMs) {
124
+ return spawnSync(lintCommandForFiles(lintCommand, files), {
125
+ cwd,
126
+ shell: true,
127
+ encoding: 'utf8',
128
+ timeout: timeoutMs,
129
+ });
130
+ }
131
+
132
+ /** Denies when the lint run could not spawn or reported failures; otherwise returns. */
133
+ function denyIfLintFailed(result, lintCommand, escapeHatch) {
134
+ if (result.error) {
135
+ deny(
136
+ GATE_ID,
137
+ `Could not run the lint command ("${lintCommand}") over the staged files: ` +
138
+ `${result.error.message}. Fix the lint setup or set lintCommand/` +
139
+ `${CONFIG_KEY} in .ai/config.json.`,
140
+ );
141
+ }
142
+ if (result.status !== 0) {
143
+ const combinedOutput =
144
+ `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
145
+ const outputTail = tailLines(combinedOutput, OUTPUT_TAIL_LINES);
146
+ deny(
147
+ GATE_ID,
148
+ `The files you staged fail lint — commit blocked until your own changes are clean ` +
149
+ `(the rest of the repo is not checked). Fix them, or add "${escapeHatch}" to the ` +
150
+ `commit command for a deliberate exception:\n${outputTail}`,
151
+ );
152
+ }
153
+ }
154
+
155
+ runGate(
156
+ {
157
+ id: GATE_ID,
158
+ configKey: CONFIG_KEY,
159
+ enabledByDefault: false,
160
+ defaultParams: {
161
+ lintCommand: null,
162
+ lintExtensions: DEFAULT_LINT_EXTENSIONS,
163
+ lintTimeoutMs: DEFAULT_LINT_TIMEOUT_MS,
164
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
165
+ },
166
+ },
167
+ ({ toolName, toolInput, parameters }) => {
168
+ if (!toolInGroups(toolName, SHELL_GROUPS)) return;
169
+
170
+ const command = commandTextFrom(toolInput);
171
+ if (!isGitCommit(command)) return;
172
+
173
+ const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
174
+ if (escapeHatch && command.includes(escapeHatch)) return;
175
+
176
+ const cwd = process.cwd();
177
+ const stagedFiles = stagedFilesToLint(cwd, parameters.lintExtensions);
178
+ if (stagedFiles.length === 0) return; // nothing you staged is in scope: nothing to enforce
179
+
180
+ const lintCommand = resolveLintCommand(cwd, parameters.lintCommand);
181
+ if (!lintCommand) return; // no linter declared/detected: never invent one
182
+
183
+ const timeoutMs = parameters.lintTimeoutMs ?? DEFAULT_LINT_TIMEOUT_MS;
184
+ const result = runLintOverStaged(lintCommand, stagedFiles, cwd, timeoutMs);
185
+ denyIfLintFailed(result, lintCommand, escapeHatch);
186
+ },
187
+ );
@@ -1,164 +1,169 @@
1
- // stop-pending — the hook that obliges the assistant not to leave pending tasks behind.
2
- // On the Stop event, if the project's task store (`.ai/tasks/active.json`) has ACTIVE
3
- // tasks (status open/in_forge — see allowStopWithBlockedTasks for `blocked`), this BLOCKS
4
- // the stop and lists them with how to close each: `task close --evidence` or `task
5
- // abandon`.
6
- //
7
- // justification: no existing tool covers this. The tasks plugin (register-requests.mjs,
8
- // session-tasks.mjs) reminds and recites; nothing in this repo stops the agent from ending
9
- // a turn while a task is still open — that requires a Stop hook that can BLOCK, which only
10
- // this event shape supports.
11
- //
12
- // ── Stop hook event shape (Claude Code) ──────────────────────────────────────────────
13
- // stdin carries JSON: { session_id, stop_hook_active, ... }. To block the stop (make the
14
- // agent continue instead of ending the turn), print {"decision":"block","reason":"..."}
15
- // and exit 0 — `reason` is shown to the model so it knows what to do next. To allow the
16
- // stop, print nothing (or {}) and exit 0.
17
- //
18
- // ── stop_hook_active loop-guard (mandatory) ──────────────────────────────────────────
19
- // If a Stop hook already blocked once in this cycle, Claude Code re-invokes Stop hooks
20
- // with stop_hook_active=true. Blocking AGAIN here would never let the turn end — an
21
- // infinite loop. So: whenever stop_hook_active === true, this gate unconditionally allows
22
- // the stop, even if pending tasks remain. That trades "always caught" for "never hangs the
23
- // session" — a single missed reminder is recoverable, a frozen session is not.
24
- //
25
- // ── Why this re-reads active.json directly instead of importing task-store.mjs ──────
26
- // The task store lives in a DIFFERENT plugin (plugins/tasks/hooks/lib/task-store.mjs).
27
- // Importing across plugin boundaries makes each plugin's installability depend on the
28
- // other being present at a specific relative path — exactly the coupling every other gate
29
- // in this repo avoids by being self-contained (Node built-ins only). This gate instead
30
- // re-reads the on-disk JSON shape directly: `{ tasks: [ { id, title, status, ... } ] }`
31
- // with status one of open/blocked/in_forge/done/abandoned (done/abandoned already live
32
- // only in history.json, never in active.json, by task-store's own close() contract — so
33
- // active.json is never filtered by status here beyond blocked/allowStopWithBlockedTasks).
34
- // A change to that shape would need updating in two places, but a cross-plugin import
35
- // would need the OTHER plugin installed at all, which is worse for a gate meant to work
36
- // standalone.
37
- //
38
- // ── What a project can configure (params) ───────────────────────────────────────────
39
- // allowStopWithBlockedTasks when true (default), a task with status 'blocked' (a
40
- // declared cause + a next condition) does NOT prevent stop —
41
- // only open/in_forge do. Set false to also block on 'blocked'.
42
- //
43
- // ── Fail-safe shape (mandatory for a Stop hook) ──────────────────────────────────────
44
- // Everything below is wrapped in try/catch. No plugin/tasks installed, no .ai/tasks/, a
45
- // corrupt active.json, or ANY unexpected error: allow the stop. A broken Stop hook must
46
- // never hang a session — that is worse than one missed reminder.
47
-
48
- import { existsSync, readFileSync } from 'node:fs';
49
- import { dirname, join } from 'node:path';
50
- import { isGateEnabled, gateParameters } from '../../lib/config.mjs';
51
- import { readHookPayload } from '../../lib/hook-io.mjs';
52
-
53
- const GATE_ID = 'stop-pending';
54
- const CONFIG_KEY = 'blockStopWithPendingTasks';
55
-
56
- const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
57
- const ACTIVE_TASKS_FILE = join('.ai', 'tasks', 'active.json');
58
- const BLOCKING_STATUSES = new Set(['open', 'in_forge']);
59
- const BLOCKED_STATUS = 'blocked';
60
-
61
- function stripBom(text) {
62
- return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
63
- }
64
-
65
- function projectRootOf(startDirectory) {
66
- let current = startDirectory;
67
- while (true) {
68
- if (PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))) {
69
- return current;
70
- }
71
- const parent = dirname(current);
72
- if (parent === current) return null;
73
- current = parent;
74
- }
75
- }
76
-
77
- /** Reads .ai/tasks/active.json's task list directly. [] on any absence/corruption. */
78
- function readActiveTasks(root) {
79
- const path = join(root, ACTIVE_TASKS_FILE);
80
- if (!existsSync(path)) return [];
81
- try {
82
- const parsed = JSON.parse(stripBom(readFileSync(path, 'utf8')));
83
- return Array.isArray(parsed.tasks) ? parsed.tasks : [];
84
- } catch {
85
- return [];
86
- }
87
- }
88
-
89
- function blockingTasksFrom(tasks, allowBlocked) {
90
- return tasks.filter((task) => {
91
- if (BLOCKING_STATUSES.has(task.status)) return true;
92
- return !allowBlocked && task.status === BLOCKED_STATUS;
93
- });
94
- }
95
-
96
- function describeTasks(tasks) {
97
- return tasks
98
- .map(
99
- (task) =>
100
- ` - [${task.status}] ${task.id}: ${task.title ?? '(untitled)'}` +
101
- (task.status === BLOCKED_STATUS && task.blockedReason
102
- ? ` (blocked: ${task.blockedReason})`
103
- : ''),
104
- )
105
- .join('\n');
106
- }
107
-
108
- function block(reason) {
109
- process.stdout.write(JSON.stringify({ decision: 'block', reason }));
110
- process.exit(0);
111
- }
112
-
113
- function allow() {
114
- process.exit(0);
115
- }
116
-
117
- function main() {
118
- try {
119
- const rawPayload = readHookPayload();
120
- if (rawPayload === null) return allow();
121
-
122
- let payload;
123
- try {
124
- payload = JSON.parse(rawPayload);
125
- } catch {
126
- return allow(); // unparseable Stop payload: fail-safe, never hang the session
127
- }
128
-
129
- // Loop-guard: a Stop hook already blocked once this cycle. Never block again.
130
- if (payload?.stop_hook_active === true) return allow();
131
-
132
- const cwd = process.cwd();
133
- // registryDefault MUST match this gate's `default` in registry.json (true). The gate
134
- // does not read the registry this literal IS its default when a project config is
135
- // silent about the key. It was false while the registry said false; both moved to true
136
- // so the pending-task reminder actually fires on a fresh install, not only when the
137
- // user's config names the key explicitly.
138
- if (!isGateEnabled(CONFIG_KEY, true, cwd)) return allow();
139
-
140
- const root = projectRootOf(cwd);
141
- if (!root) return allow(); // no project: nothing to check
142
-
143
- const parameters = gateParameters(CONFIG_KEY, cwd);
144
- const allowBlocked = parameters.allowStopWithBlockedTasks !== false; // default true
145
-
146
- const tasks = readActiveTasks(root);
147
- const blockingTasks = blockingTasksFrom(tasks, allowBlocked);
148
- if (blockingTasks.length === 0) return allow();
149
-
150
- return block(
151
- `[${GATE_ID}] There are ${blockingTasks.length} pending task(s) still active for this ` +
152
- `project:\n${describeTasks(blockingTasks)}\n` +
153
- 'Close each before ending the turn: `task close <id> --evidence "..."` when done, ' +
154
- 'or `task abandon <id> --reason "..."` when it will not be finished. A task left ' +
155
- '`blocked` with a stated cause does not require closing unless ' +
156
- 'allowStopWithBlockedTasks is set to false.',
157
- );
158
- } catch {
159
- // A broken Stop hook must never hang the session.
160
- return allow();
161
- }
162
- }
163
-
164
- main();
1
+ // stop-pending — the hook that obliges the assistant not to leave pending tasks behind.
2
+ // On the Stop event, if the project's task store (`.ai/tasks/active.json`) has ACTIVE
3
+ // tasks (status open/in_forge — see allowStopWithBlockedTasks for `blocked`), this BLOCKS
4
+ // the stop and lists them with how to close each: `task close --evidence` or `task
5
+ // abandon`.
6
+ //
7
+ // justification: no existing tool covers this. The tasks plugin (register-requests.mjs,
8
+ // session-tasks.mjs) reminds and recites; nothing in this repo stops the agent from ending
9
+ // a turn while a task is still open — that requires a Stop hook that can BLOCK, which only
10
+ // this event shape supports.
11
+ //
12
+ // ── Stop hook event shape (Claude Code) ──────────────────────────────────────────────
13
+ // stdin carries JSON: { session_id, stop_hook_active, ... }. To block the stop (make the
14
+ // agent continue instead of ending the turn), print {"decision":"block","reason":"..."}
15
+ // and exit 0 — `reason` is shown to the model so it knows what to do next. To allow the
16
+ // stop, print nothing (or {}) and exit 0.
17
+ //
18
+ // ── stop_hook_active loop-guard (mandatory) ──────────────────────────────────────────
19
+ // If a Stop hook already blocked once in this cycle, Claude Code re-invokes Stop hooks
20
+ // with stop_hook_active=true. Blocking AGAIN here would never let the turn end — an
21
+ // infinite loop. So: whenever stop_hook_active === true, this gate unconditionally allows
22
+ // the stop, even if pending tasks remain. That trades "always caught" for "never hangs the
23
+ // session" — a single missed reminder is recoverable, a frozen session is not.
24
+ //
25
+ // ── Why this re-reads active.json directly instead of importing task-store.mjs ──────
26
+ // The task store lives in a DIFFERENT plugin (plugins/tasks/hooks/lib/task-store.mjs).
27
+ // Importing across plugin boundaries makes each plugin's installability depend on the
28
+ // other being present at a specific relative path — exactly the coupling every other gate
29
+ // in this repo avoids by being self-contained (Node built-ins only). This gate instead
30
+ // re-reads the on-disk JSON shape directly: `{ tasks: [ { id, title, status, ... } ] }`
31
+ // with status one of open/blocked/in_forge/done/abandoned (done/abandoned already live
32
+ // only in history.json, never in active.json, by task-store's own close() contract — so
33
+ // active.json is never filtered by status here beyond blocked/allowStopWithBlockedTasks).
34
+ // A change to that shape would need updating in two places, but a cross-plugin import
35
+ // would need the OTHER plugin installed at all, which is worse for a gate meant to work
36
+ // standalone.
37
+ //
38
+ // ── What a project can configure (params) ───────────────────────────────────────────
39
+ // allowStopWithBlockedTasks when true (default), a task with status 'blocked' (a
40
+ // declared cause + a next condition) does NOT prevent stop —
41
+ // only open/in_forge do. Set false to also block on 'blocked'.
42
+ //
43
+ // ── Fail-safe shape (mandatory for a Stop hook) ──────────────────────────────────────
44
+ // Everything below is wrapped in try/catch. No plugin/tasks installed, no .ai/tasks/, a
45
+ // corrupt active.json, or ANY unexpected error: allow the stop. A broken Stop hook must
46
+ // never hang a session — that is worse than one missed reminder.
47
+
48
+ import { existsSync, readFileSync } from 'node:fs';
49
+ import { dirname, join } from 'node:path';
50
+ import { isGateEnabled, gateParameters } from '../../lib/config.mjs';
51
+ import { readHookPayload } from '../../lib/hook-io.mjs';
52
+
53
+ const GATE_ID = 'stop-pending';
54
+ const CONFIG_KEY = 'blockStopWithPendingTasks';
55
+
56
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
57
+ const ACTIVE_TASKS_FILE = join('.ai', 'tasks', 'active.json');
58
+ const BLOCKING_STATUSES = new Set(['open', 'in_forge']);
59
+ const BLOCKED_STATUS = 'blocked';
60
+
61
+ const BOM_CODE_POINT = 0xfeff;
62
+
63
+ function stripBom(text) {
64
+ return text.charCodeAt(0) === BOM_CODE_POINT ? text.slice(1) : text;
65
+ }
66
+
67
+ function projectRootOf(startDirectory) {
68
+ let current = startDirectory;
69
+ while (true) {
70
+ if (
71
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
72
+ ) {
73
+ return current;
74
+ }
75
+ const parent = dirname(current);
76
+ if (parent === current) return null;
77
+ current = parent;
78
+ }
79
+ }
80
+
81
+ /** Reads .ai/tasks/active.json's task list directly. [] on any absence/corruption. */
82
+ function readActiveTasks(root) {
83
+ const path = join(root, ACTIVE_TASKS_FILE);
84
+ if (!existsSync(path)) return [];
85
+ try {
86
+ const parsed = JSON.parse(stripBom(readFileSync(path, 'utf8')));
87
+ return Array.isArray(parsed.tasks) ? parsed.tasks : [];
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ function blockingTasksFrom(tasks, allowBlocked) {
94
+ return tasks.filter((task) => {
95
+ if (BLOCKING_STATUSES.has(task.status)) return true;
96
+ return !allowBlocked && task.status === BLOCKED_STATUS;
97
+ });
98
+ }
99
+
100
+ function describeTasks(tasks) {
101
+ return tasks
102
+ .map(
103
+ (task) =>
104
+ ` - [${task.status}] ${task.id}: ${task.title ?? '(untitled)'}${
105
+ task.status === BLOCKED_STATUS && task.blockedReason
106
+ ? ` (blocked: ${task.blockedReason})`
107
+ : ''
108
+ }`,
109
+ )
110
+ .join('\n');
111
+ }
112
+
113
+ function block(reason) {
114
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
115
+ process.exit(0);
116
+ }
117
+
118
+ function allow() {
119
+ process.exit(0);
120
+ }
121
+
122
+ function main() {
123
+ try {
124
+ const rawPayload = readHookPayload();
125
+ if (rawPayload === null) return allow();
126
+
127
+ let payload;
128
+ try {
129
+ payload = JSON.parse(rawPayload);
130
+ } catch {
131
+ return allow(); // unparseable Stop payload: fail-safe, never hang the session
132
+ }
133
+
134
+ // Loop-guard: a Stop hook already blocked once this cycle. Never block again.
135
+ if (payload?.stop_hook_active === true) return allow();
136
+
137
+ const cwd = process.cwd();
138
+ // registryDefault MUST match this gate's `default` in registry.json (true). The gate
139
+ // does not read the registry — this literal IS its default when a project config is
140
+ // silent about the key. It was false while the registry said false; both moved to true
141
+ // so the pending-task reminder actually fires on a fresh install, not only when the
142
+ // user's config names the key explicitly.
143
+ if (!isGateEnabled(CONFIG_KEY, true, cwd)) return allow();
144
+
145
+ const root = projectRootOf(cwd);
146
+ if (!root) return allow(); // no project: nothing to check
147
+
148
+ const parameters = gateParameters(CONFIG_KEY, cwd);
149
+ const allowBlocked = parameters.allowStopWithBlockedTasks !== false; // default true
150
+
151
+ const tasks = readActiveTasks(root);
152
+ const blockingTasks = blockingTasksFrom(tasks, allowBlocked);
153
+ if (blockingTasks.length === 0) return allow();
154
+
155
+ return block(
156
+ `[${GATE_ID}] There are ${blockingTasks.length} pending task(s) still active for this ` +
157
+ `project:\n${describeTasks(blockingTasks)}\n` +
158
+ 'Close each before ending the turn: `task close <id> --evidence "..."` when done, ' +
159
+ 'or `task abandon <id> --reason "..."` when it will not be finished. A task left ' +
160
+ '`blocked` with a stated cause does not require closing unless ' +
161
+ 'allowStopWithBlockedTasks is set to false.',
162
+ );
163
+ } catch {
164
+ // A broken Stop hook must never hang the session.
165
+ return allow();
166
+ }
167
+ }
168
+
169
+ main();