@devrik-tools/claude-gates 0.1.2 → 0.2.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -0,0 +1,227 @@
1
+ // wiring-check — SessionStart hook. Cross-checks registry.json, each plugin's hooks.json and
2
+ // the scripts on disk, and warns (never blocks — SessionStart cannot deny) when they
3
+ // disagree. This is the check that would have caught the session family itself being
4
+ // declared in registry.json with no script and no hooks.json wiring: three checks,
5
+ // each catching a different half of a broken install —
6
+ //
7
+ // 1. registry gate -> not wired: a gate.json entry exists but no hooks.json (of the
8
+ // family's plugin) references its script for the declared event.
9
+ // 2. hooks.json command -> script missing: a hooks.json entry points at a script path
10
+ // that does not exist on disk.
11
+ // 3. orphaned gate folder: a `gates/<id>/index.mjs` exists under a plugin's hooks/ dir
12
+ // but registry.json has no gate pointing at it.
13
+ //
14
+ // Consistent (nothing to report) -> silent. Fail-safe: wrapped in try/catch, a bug here
15
+ // must never block session start.
16
+ //
17
+ // justification: no existing tool covers this. `cli/__tests__/registry-gates-consistency
18
+ // .test.mjs` checks registry-vs-disk at CI/dev time; this is the SAME class of check
19
+ // surfaced live, at session start, in an already-installed plugin where the source
20
+ // checkout (and that test) is not present.
21
+
22
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { dirname, join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const STDIN_FILE_DESCRIPTOR = 0;
28
+ const SESSION_START_EVENT = 'SessionStart';
29
+ const CONFIG_KEY = 'checkWiringOnStart';
30
+
31
+ const HOOKS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
32
+ const REPOSITORY_ROOT = join(HOOKS_DIRECTORY, '..', '..', '..');
33
+ const REGISTRY_PATH = join(REPOSITORY_ROOT, 'registry.json');
34
+ const DEFAULT_PLUGIN = 'gates';
35
+
36
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
37
+ const PROJECT_CONFIG = join('.ai', 'config.json');
38
+ const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
39
+
40
+ const BOM_CHAR_CODE = 0xfeff;
41
+
42
+ /** Strips a UTF-8 BOM a Windows editor/tool may have written before the JSON. */
43
+ function stripBom(text) {
44
+ return text.charCodeAt(0) === BOM_CHAR_CODE ? text.slice(1) : text;
45
+ }
46
+
47
+ function readJson(path) {
48
+ try {
49
+ return JSON.parse(stripBom(readFileSync(path, 'utf8')));
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function projectRootOf(startDirectory) {
56
+ let current = startDirectory;
57
+ while (true) {
58
+ if (
59
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
60
+ ) {
61
+ return current;
62
+ }
63
+ const parent = dirname(current);
64
+ if (parent === current) return null;
65
+ current = parent;
66
+ }
67
+ }
68
+
69
+ /** The wiring-check gate config entry (project overrides global), or null. */
70
+ function wiringConfig(startDirectory) {
71
+ const root = projectRootOf(startDirectory);
72
+ const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
73
+ const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
74
+ const layer = projectData?.gates ?? globalData?.gates ?? {};
75
+ const entry = layer[CONFIG_KEY];
76
+ if (typeof entry === 'boolean') return { enabled: entry };
77
+ if (entry && typeof entry === 'object') return entry;
78
+ return null;
79
+ }
80
+
81
+ function readPayload() {
82
+ try {
83
+ return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ function pluginHooksDirectory(pluginName) {
90
+ return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks');
91
+ }
92
+
93
+ function pluginHooksJsonPath(pluginName) {
94
+ return join(pluginHooksDirectory(pluginName), 'hooks.json');
95
+ }
96
+
97
+ /** Every hooks.json command string across all its events, flattened. */
98
+ function commandsIn(hooksJson) {
99
+ if (!hooksJson?.hooks) return [];
100
+ const commands = [];
101
+ for (const entries of Object.values(hooksJson.hooks)) {
102
+ for (const entry of entries ?? []) {
103
+ for (const hook of entry?.hooks ?? []) {
104
+ if (hook?.command) commands.push(hook.command);
105
+ }
106
+ }
107
+ }
108
+ return commands;
109
+ }
110
+
111
+ /** Whether some hooks.json command references this script's basename for this plugin. */
112
+ function scriptIsWired(commands, script) {
113
+ // Commands look like `node "${CLAUDE_PLUGIN_ROOT}/hooks/gates/x/index.mjs"` — match on
114
+ // the script's path-with-forward-slashes so it is independent of the wrapping command
115
+ // shape (quoting, the node invocation, timeout, etc.).
116
+ const needle = script.replace(/\\/g, '/');
117
+ return commands.some((command) =>
118
+ command.replace(/\\/g, '/').includes(needle),
119
+ );
120
+ }
121
+
122
+ function checkRegistryVsHooksJson(registry) {
123
+ const problems = [];
124
+ const hooksJsonCache = new Map();
125
+
126
+ for (const family of registry.families ?? []) {
127
+ const pluginName = family.plugin ?? DEFAULT_PLUGIN;
128
+ if (!hooksJsonCache.has(pluginName)) {
129
+ hooksJsonCache.set(pluginName, readJson(pluginHooksJsonPath(pluginName)));
130
+ }
131
+ const hooksJson = hooksJsonCache.get(pluginName);
132
+ const commands = commandsIn(hooksJson);
133
+
134
+ for (const gate of family.gates ?? []) {
135
+ const scriptPath = join(pluginHooksDirectory(pluginName), gate.script);
136
+ if (!existsSync(scriptPath)) {
137
+ problems.push(
138
+ `gate "${gate.id}": script missing on disk (expected plugins/${pluginName}/hooks/${gate.script})`,
139
+ );
140
+ continue; // no point checking wiring for a script that isn't even there
141
+ }
142
+ if (!scriptIsWired(commands, gate.script)) {
143
+ problems.push(
144
+ `gate "${gate.id}": not wired in plugins/${pluginName}/hooks/hooks.json (${gate.event})`,
145
+ );
146
+ }
147
+ }
148
+ }
149
+ return problems;
150
+ }
151
+
152
+ /** Gate folders (gates/<id>/index.mjs) on disk with no registry entry pointing at them. */
153
+ function orphanedGateFolders(registry) {
154
+ const declaredScripts = new Set(
155
+ (registry.families ?? []).flatMap((family) =>
156
+ (family.gates ?? [])
157
+ .filter((gate) => gate.script.startsWith('gates/'))
158
+ .map((gate) => `${family.plugin ?? DEFAULT_PLUGIN}::${gate.script}`),
159
+ ),
160
+ );
161
+
162
+ const problems = [];
163
+ const pluginNames = new Set(
164
+ (registry.families ?? []).map((family) => family.plugin ?? DEFAULT_PLUGIN),
165
+ );
166
+ for (const pluginName of pluginNames) {
167
+ const gatesDirectory = join(pluginHooksDirectory(pluginName), 'gates');
168
+ if (!existsSync(gatesDirectory)) continue;
169
+ let entries;
170
+ try {
171
+ entries = readdirSync(gatesDirectory, { withFileTypes: true });
172
+ } catch {
173
+ continue;
174
+ }
175
+ for (const entry of entries) {
176
+ const script = `gates/${entry.name}/index.mjs`;
177
+ const isRealGateFolder =
178
+ entry.isDirectory() &&
179
+ existsSync(join(gatesDirectory, entry.name, 'index.mjs'));
180
+ if (!isRealGateFolder) continue;
181
+ if (!declaredScripts.has(`${pluginName}::${script}`)) {
182
+ problems.push(
183
+ `plugins/${pluginName}/hooks/gates/${entry.name}/index.mjs has no registry entry`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+ return problems;
189
+ }
190
+
191
+ function speak(context) {
192
+ process.stdout.write(
193
+ JSON.stringify({
194
+ hookSpecificOutput: {
195
+ hookEventName: SESSION_START_EVENT,
196
+ additionalContext: context,
197
+ },
198
+ }),
199
+ );
200
+ }
201
+
202
+ function main() {
203
+ const payload = readPayload();
204
+ const cwd = payload.cwd || process.cwd();
205
+
206
+ const config = wiringConfig(cwd);
207
+ if (config && config.enabled === false) return; // gate turned off for this project
208
+
209
+ const registry = readJson(REGISTRY_PATH);
210
+ if (!registry?.families) return; // can't read the registry: nothing to report
211
+
212
+ const problems = [
213
+ ...checkRegistryVsHooksJson(registry),
214
+ ...orphanedGateFolders(registry),
215
+ ];
216
+ if (problems.length === 0) return; // everything consistent: stay silent
217
+
218
+ speak(
219
+ `[wiring-check] claude-gates wiring inconsistencies found:\n- ${problems.join('\n- ')}`,
220
+ );
221
+ }
222
+
223
+ try {
224
+ main();
225
+ } catch {
226
+ // A wiring-check bug must never block session start.
227
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "tasks",
3
+ "version": "0.2.0",
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
+ "author": {
6
+ "name": "Devrik"
7
+ },
8
+ "keywords": [
9
+ "hooks",
10
+ "tasks",
11
+ "UserPromptSubmit",
12
+ "SessionStart"
13
+ ]
14
+ }
@@ -0,0 +1,100 @@
1
+ // register-requests — UserPromptSubmit hook. Spawned as a real child process, matching how
2
+ // Claude Code invokes it, against a temp project.
3
+
4
+ import assert from 'node:assert/strict';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ import { test } from 'node:test';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { openTaskStore } from '../lib/task-store.mjs';
12
+
13
+ const HOOK_PATH = join(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ '..',
16
+ 'register-requests.mjs',
17
+ );
18
+
19
+ function makeProject() {
20
+ const project = mkdtempSync(join(tmpdir(), 'register-requests-'));
21
+ mkdirSync(join(project, '.git'));
22
+ return project;
23
+ }
24
+
25
+ function runHook(cwd) {
26
+ return execFileSync(process.execPath, [HOOK_PATH], {
27
+ input: JSON.stringify({ cwd }),
28
+ encoding: 'utf8',
29
+ });
30
+ }
31
+
32
+ function writeConfig(project, gates) {
33
+ mkdirSync(join(project, '.ai'), { recursive: true });
34
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify({ gates }));
35
+ }
36
+
37
+ function sampleTask(id) {
38
+ return {
39
+ id,
40
+ title: `task ${id}`,
41
+ description: '',
42
+ status: 'open',
43
+ size: 'trivial',
44
+ createdAt: new Date().toISOString(),
45
+ messages: [],
46
+ };
47
+ }
48
+
49
+ test('always asks the model to classify+register, even with no active tasks yet', () => {
50
+ const project = makeProject();
51
+ const stdout = runHook(project);
52
+ assert.match(stdout, /claude-gates task add/);
53
+ });
54
+
55
+ test('does NOT recite active tasks before remindEveryMessages is reached', () => {
56
+ const project = makeProject();
57
+ const store = openTaskStore(project);
58
+ store.add(sampleTask('t1'));
59
+ writeConfig(project, { remindOpenTasks: { enabled: true, remindEveryMessages: 3 } });
60
+
61
+ const first = runHook(project);
62
+ assert.doesNotMatch(first, /OPEN TASKS/);
63
+ const second = runHook(project);
64
+ assert.doesNotMatch(second, /OPEN TASKS/);
65
+ });
66
+
67
+ test('recites active tasks once remindEveryMessages is reached, then resets the counter', () => {
68
+ const project = makeProject();
69
+ const store = openTaskStore(project);
70
+ store.add(sampleTask('t1'));
71
+ writeConfig(project, { remindOpenTasks: { enabled: true, remindEveryMessages: 2 } });
72
+
73
+ runHook(project); // 1st call: counter -> 1, no reminder
74
+ const forced = runHook(project); // 2nd call: counter hits threshold -> reminder + reset
75
+ assert.match(forced, /OPEN TASKS/);
76
+ assert.match(forced, /t1/);
77
+
78
+ const afterReset = runHook(project); // 3rd call: counter just reset -> no reminder yet
79
+ assert.doesNotMatch(afterReset, /OPEN TASKS/);
80
+ });
81
+
82
+ test('remindOpenTasks:false silences the hook entirely (no classify prompt, no reminder)', () => {
83
+ const project = makeProject();
84
+ const store = openTaskStore(project);
85
+ store.add(sampleTask('t1'));
86
+ writeConfig(project, { remindOpenTasks: false });
87
+
88
+ const stdout = runHook(project);
89
+ assert.equal(stdout, '');
90
+ });
91
+
92
+ test('defaults-dump mode reports remindEveryMessages default without touching stdin', () => {
93
+ const out = execFileSync(process.execPath, [HOOK_PATH], {
94
+ encoding: 'utf8',
95
+ env: { ...process.env, CLAUDE_GATES_DUMP_DEFAULTS: '1' },
96
+ });
97
+ const parsed = JSON.parse(out);
98
+ assert.equal(parsed.configKey, 'remindOpenTasks');
99
+ assert.equal(parsed.defaultParams.remindEveryMessages, 5);
100
+ });
@@ -0,0 +1,95 @@
1
+ // session-tasks — SessionStart hook. Spawned as a real child process with a JSON payload on
2
+ // stdin, exactly as Claude Code invokes it, against a temp project (never this repo's own
3
+ // .ai/ state).
4
+
5
+ import assert from 'node:assert/strict';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { dirname, join } from 'node:path';
10
+ import { test } from 'node:test';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { openTaskStore, STATUS } from '../lib/task-store.mjs';
13
+
14
+ const HOOK_PATH = join(
15
+ dirname(fileURLToPath(import.meta.url)),
16
+ '..',
17
+ 'session-tasks.mjs',
18
+ );
19
+
20
+ function makeProject() {
21
+ const project = mkdtempSync(join(tmpdir(), 'session-tasks-'));
22
+ mkdirSync(join(project, '.git'));
23
+ return project;
24
+ }
25
+
26
+ function runHook(cwd) {
27
+ return execFileSync(process.execPath, [HOOK_PATH], {
28
+ input: JSON.stringify({ cwd }),
29
+ encoding: 'utf8',
30
+ });
31
+ }
32
+
33
+ function sampleTask(id, status = STATUS.OPEN) {
34
+ return {
35
+ id,
36
+ title: `task ${id}`,
37
+ description: '',
38
+ status,
39
+ size: 'trivial',
40
+ createdAt: new Date().toISOString(),
41
+ messages: [],
42
+ };
43
+ }
44
+
45
+ test('stays silent (empty stdout) when there are no active tasks', () => {
46
+ const project = makeProject();
47
+ const stdout = runHook(project);
48
+ assert.equal(stdout, '');
49
+ });
50
+
51
+ test('lists active tasks (including in_forge, marked) when some exist', () => {
52
+ const project = makeProject();
53
+ const store = openTaskStore(project);
54
+ store.add(sampleTask('t1'));
55
+ store.add(sampleTask('t2', STATUS.IN_FORGE));
56
+
57
+ const stdout = runHook(project);
58
+ assert.notEqual(stdout, '');
59
+ const payload = JSON.parse(stdout);
60
+ const context = payload.hookSpecificOutput.additionalContext;
61
+ assert.match(context, /t1/);
62
+ assert.match(context, /t2/);
63
+ assert.match(context, /in_forge/);
64
+ assert.equal(payload.hookSpecificOutput.hookEventName, 'SessionStart');
65
+ });
66
+
67
+ test('a task closed (done/abandoned) does not appear in the session-start list', () => {
68
+ const project = makeProject();
69
+ const store = openTaskStore(project);
70
+ store.add(sampleTask('t1'));
71
+ store.add(sampleTask('t2'));
72
+ store.close('t2', STATUS.ABANDONED, { reason: 'dropped' });
73
+
74
+ const stdout = runHook(project);
75
+ const context = JSON.parse(stdout).hookSpecificOutput.additionalContext;
76
+ assert.match(context, /t1/);
77
+ assert.doesNotMatch(context, /t2/);
78
+ });
79
+
80
+ test('listTasksOnSessionStart:false in project config silences the hook even with active tasks', () => {
81
+ const project = makeProject();
82
+ const store = openTaskStore(project);
83
+ store.add(sampleTask('t1'));
84
+
85
+ mkdirSync(join(project, '.ai'), { recursive: true });
86
+ writeFileSync(
87
+ join(project, '.ai', 'config.json'),
88
+ JSON.stringify({
89
+ gates: { listTasksOnSessionStart: false },
90
+ }),
91
+ );
92
+
93
+ const stdout = runHook(project);
94
+ assert.equal(stdout, '');
95
+ });
@@ -0,0 +1,26 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/register-requests.mjs\"",
9
+ "timeout": 10
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "SessionStart": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-tasks.mjs\"",
20
+ "timeout": 10
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }