@hasnaxyz/hook-checktasks 0.1.1
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.
- package/README.md +152 -0
- package/dist/cli.js +394 -0
- package/dist/hook.js +131 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# @hasnaxyz/hook-checktasks
|
|
2
|
+
|
|
3
|
+
A Claude Code hook that prevents Claude from stopping when there are pending tasks in your task list.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When you have a task list configured (`CLAUDE_CODE_TASK_LIST_ID`), this hook:
|
|
8
|
+
|
|
9
|
+
1. Checks if the session name or task list ID contains "dev" (configurable)
|
|
10
|
+
2. Reads the task list from `~/.claude/tasks/{taskListId}/`
|
|
11
|
+
3. If there are pending or in-progress tasks, **blocks the stop** and prompts Claude to continue working
|
|
12
|
+
4. Only allows stopping when all tasks are completed
|
|
13
|
+
|
|
14
|
+
This ensures Claude doesn't abandon work mid-session.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### Prerequisites
|
|
19
|
+
|
|
20
|
+
- [Bun](https://bun.sh/) runtime installed
|
|
21
|
+
- Access to `@hasnaxyz` npm organization
|
|
22
|
+
|
|
23
|
+
### Quick Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Install globally (applies to all Claude Code sessions)
|
|
27
|
+
bunx @hasnaxyz/hook-checktasks install --global
|
|
28
|
+
|
|
29
|
+
# Install for current project only
|
|
30
|
+
bunx @hasnaxyz/hook-checktasks install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Check Installation Status
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bunx @hasnaxyz/hook-checktasks status
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Uninstall
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Remove from global settings
|
|
43
|
+
bunx @hasnaxyz/hook-checktasks uninstall --global
|
|
44
|
+
|
|
45
|
+
# Remove from current project
|
|
46
|
+
bunx @hasnaxyz/hook-checktasks uninstall
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Configure via environment variables:
|
|
52
|
+
|
|
53
|
+
| Variable | Description | Default |
|
|
54
|
+
|----------|-------------|---------|
|
|
55
|
+
| `CLAUDE_CODE_TASK_LIST_ID` | Task list to monitor (required for task checking) | - |
|
|
56
|
+
| `CHECK_TASKS_KEYWORDS` | Comma-separated keywords that trigger the check | `dev` |
|
|
57
|
+
| `CHECK_TASKS_DISABLED` | Set to `1` to disable the hook | - |
|
|
58
|
+
|
|
59
|
+
### Examples
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Standard usage with dev task list
|
|
63
|
+
CLAUDE_CODE_TASK_LIST_ID=myproject-dev claude
|
|
64
|
+
|
|
65
|
+
# Check for "dev" or "sprint" sessions
|
|
66
|
+
CHECK_TASKS_KEYWORDS=dev,sprint CLAUDE_CODE_TASK_LIST_ID=myproject-dev claude
|
|
67
|
+
|
|
68
|
+
# Temporarily disable the hook
|
|
69
|
+
CHECK_TASKS_DISABLED=1 claude
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
1. **On Stop hook trigger**: Claude is about to stop/exit
|
|
75
|
+
2. **Check session name**: Reads the session name from transcript (if available)
|
|
76
|
+
3. **Keyword matching**: Checks if session name or task list ID contains configured keywords
|
|
77
|
+
4. **Task count**: Reads task files from `~/.claude/tasks/{taskListId}/`
|
|
78
|
+
5. **Decision**:
|
|
79
|
+
- If tasks remain: Block stop with instructions to continue
|
|
80
|
+
- If all complete: Allow stop
|
|
81
|
+
|
|
82
|
+
## What Gets Added to settings.json
|
|
83
|
+
|
|
84
|
+
The install command adds this to your Claude Code settings:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"hooks": {
|
|
89
|
+
"Stop": [
|
|
90
|
+
{
|
|
91
|
+
"hooks": [
|
|
92
|
+
{
|
|
93
|
+
"type": "command",
|
|
94
|
+
"command": "bunx @hasnaxyz/hook-checktasks run",
|
|
95
|
+
"timeout": 120
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Task File Format
|
|
105
|
+
|
|
106
|
+
Tasks are stored as JSON files in `~/.claude/tasks/{taskListId}/`:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"id": "task-001",
|
|
111
|
+
"subject": "Implement user authentication",
|
|
112
|
+
"status": "pending"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Status values: `pending`, `in_progress`, `completed`
|
|
117
|
+
|
|
118
|
+
## CLI Commands
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
bunx @hasnaxyz/hook-checktasks <command> [options]
|
|
122
|
+
|
|
123
|
+
Commands:
|
|
124
|
+
install [--global] Install the hook to Claude Code settings
|
|
125
|
+
uninstall [--global] Remove the hook from Claude Code settings
|
|
126
|
+
run Execute the hook (called by Claude Code automatically)
|
|
127
|
+
status Show current hook configuration
|
|
128
|
+
|
|
129
|
+
Options:
|
|
130
|
+
--global, -g Apply to global settings (~/.claude/settings.json)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# Clone the repo
|
|
137
|
+
git clone https://github.com/hasnaxyz/hook-checktasks.git
|
|
138
|
+
cd hook-checktasks
|
|
139
|
+
|
|
140
|
+
# Install dependencies
|
|
141
|
+
bun install
|
|
142
|
+
|
|
143
|
+
# Build
|
|
144
|
+
bun run build
|
|
145
|
+
|
|
146
|
+
# Test locally
|
|
147
|
+
bun ./dist/cli.js status
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, {
|
|
8
|
+
get: all[name],
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
set: (newValue) => all[name] = () => newValue
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
15
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
// src/hook.ts
|
|
18
|
+
var exports_hook = {};
|
|
19
|
+
__export(exports_hook, {
|
|
20
|
+
run: () => run
|
|
21
|
+
});
|
|
22
|
+
import { readdirSync, readFileSync, existsSync } from "fs";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
import { homedir } from "os";
|
|
25
|
+
function readStdinJson() {
|
|
26
|
+
try {
|
|
27
|
+
const stdin = readFileSync(0, "utf-8");
|
|
28
|
+
return JSON.parse(stdin);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function getSessionName(transcriptPath) {
|
|
34
|
+
if (!existsSync(transcriptPath))
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const content = readFileSync(transcriptPath, "utf-8");
|
|
38
|
+
let lastTitle = null;
|
|
39
|
+
let searchStart = 0;
|
|
40
|
+
while (true) {
|
|
41
|
+
const titleIndex = content.indexOf('"custom-title"', searchStart);
|
|
42
|
+
if (titleIndex === -1)
|
|
43
|
+
break;
|
|
44
|
+
const lineStart = content.lastIndexOf(`
|
|
45
|
+
`, titleIndex) + 1;
|
|
46
|
+
const lineEnd = content.indexOf(`
|
|
47
|
+
`, titleIndex);
|
|
48
|
+
const line = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
|
|
49
|
+
try {
|
|
50
|
+
const entry = JSON.parse(line);
|
|
51
|
+
if (entry.type === "custom-title" && entry.customTitle) {
|
|
52
|
+
lastTitle = entry.customTitle;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
searchStart = titleIndex + 1;
|
|
56
|
+
}
|
|
57
|
+
return lastTitle;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function approve() {
|
|
63
|
+
console.log(JSON.stringify({ decision: "approve" }));
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
function block(reason) {
|
|
67
|
+
console.log(JSON.stringify({ decision: "block", reason }));
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
function run() {
|
|
71
|
+
if (process.env.CHECK_TASKS_DISABLED === "1") {
|
|
72
|
+
approve();
|
|
73
|
+
}
|
|
74
|
+
const taskListId = process.env.CLAUDE_CODE_TASK_LIST_ID;
|
|
75
|
+
if (!taskListId) {
|
|
76
|
+
approve();
|
|
77
|
+
}
|
|
78
|
+
const hookInput = readStdinJson();
|
|
79
|
+
let sessionName = null;
|
|
80
|
+
if (hookInput?.transcript_path) {
|
|
81
|
+
sessionName = getSessionName(hookInput.transcript_path);
|
|
82
|
+
}
|
|
83
|
+
const nameToCheck = sessionName || taskListId || "";
|
|
84
|
+
const keywords = (process.env.CHECK_TASKS_KEYWORDS || "dev").split(",").map((k) => k.trim().toLowerCase()).filter(Boolean);
|
|
85
|
+
const matchesKeyword = keywords.some((keyword) => nameToCheck.toLowerCase().includes(keyword));
|
|
86
|
+
if (!matchesKeyword) {
|
|
87
|
+
approve();
|
|
88
|
+
}
|
|
89
|
+
const tasksDir = join(homedir(), ".claude", "tasks", taskListId);
|
|
90
|
+
if (!existsSync(tasksDir)) {
|
|
91
|
+
approve();
|
|
92
|
+
}
|
|
93
|
+
const taskFiles = readdirSync(tasksDir).filter((f) => f.endsWith(".json"));
|
|
94
|
+
if (taskFiles.length === 0) {
|
|
95
|
+
approve();
|
|
96
|
+
}
|
|
97
|
+
const tasks = taskFiles.map((file) => {
|
|
98
|
+
const content = readFileSync(join(tasksDir, file), "utf-8");
|
|
99
|
+
return JSON.parse(content);
|
|
100
|
+
});
|
|
101
|
+
const pending = tasks.filter((t) => t.status === "pending");
|
|
102
|
+
const inProgress = tasks.filter((t) => t.status === "in_progress");
|
|
103
|
+
const completed = tasks.filter((t) => t.status === "completed");
|
|
104
|
+
const remainingCount = pending.length + inProgress.length;
|
|
105
|
+
if (remainingCount > 0) {
|
|
106
|
+
const nextTasks = pending.slice(0, 3).map((t) => `- ${t.subject}`).join(`
|
|
107
|
+
`);
|
|
108
|
+
const prompt = `
|
|
109
|
+
STOP BLOCKED: You have ${remainingCount} tasks remaining (${pending.length} pending, ${inProgress.length} in progress, ${completed.length} completed).
|
|
110
|
+
|
|
111
|
+
You MUST continue working. Do NOT stop until all tasks are completed.
|
|
112
|
+
|
|
113
|
+
Next pending tasks:
|
|
114
|
+
${nextTasks}
|
|
115
|
+
${pending.length > 3 ? `... and ${pending.length - 3} more pending tasks` : ""}
|
|
116
|
+
|
|
117
|
+
INSTRUCTIONS:
|
|
118
|
+
1. Use TaskList to see all tasks
|
|
119
|
+
2. Pick the next pending task
|
|
120
|
+
3. Use TaskUpdate to mark it as in_progress
|
|
121
|
+
4. Complete the task
|
|
122
|
+
5. Use TaskUpdate to mark it as completed
|
|
123
|
+
6. Repeat until all tasks are done
|
|
124
|
+
|
|
125
|
+
DO NOT STOP. Continue working now.
|
|
126
|
+
`.trim();
|
|
127
|
+
block(prompt);
|
|
128
|
+
}
|
|
129
|
+
approve();
|
|
130
|
+
}
|
|
131
|
+
var init_hook = __esm(() => {
|
|
132
|
+
if (false) {}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// src/cli.ts
|
|
136
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync, mkdirSync } from "fs";
|
|
137
|
+
import { join as join2, dirname, resolve } from "path";
|
|
138
|
+
import { homedir as homedir2 } from "os";
|
|
139
|
+
import * as readline from "readline";
|
|
140
|
+
var PACKAGE_NAME = "@hasnaxyz/hook-checktasks";
|
|
141
|
+
var c = {
|
|
142
|
+
red: (s) => `\x1B[31m${s}\x1B[0m`,
|
|
143
|
+
green: (s) => `\x1B[32m${s}\x1B[0m`,
|
|
144
|
+
yellow: (s) => `\x1B[33m${s}\x1B[0m`,
|
|
145
|
+
cyan: (s) => `\x1B[36m${s}\x1B[0m`,
|
|
146
|
+
dim: (s) => `\x1B[2m${s}\x1B[0m`,
|
|
147
|
+
bold: (s) => `\x1B[1m${s}\x1B[0m`
|
|
148
|
+
};
|
|
149
|
+
function printUsage() {
|
|
150
|
+
console.log(`
|
|
151
|
+
${c.bold("hook-checktasks")} - Prevents Claude from stopping with pending tasks
|
|
152
|
+
|
|
153
|
+
${c.bold("USAGE:")}
|
|
154
|
+
hook-checktasks install [path] Install the hook
|
|
155
|
+
hook-checktasks uninstall [path] Remove the hook
|
|
156
|
+
hook-checktasks status Show hook status
|
|
157
|
+
hook-checktasks run Execute hook ${c.dim("(called by Claude Code)")}
|
|
158
|
+
|
|
159
|
+
${c.bold("INSTALL OPTIONS:")}
|
|
160
|
+
${c.dim("(no args)")} Auto-detect: if in git repo \u2192 install there, else \u2192 prompt
|
|
161
|
+
--global, -g Install to ~/.claude/settings.json
|
|
162
|
+
/path/to/repo Install to specific project path
|
|
163
|
+
|
|
164
|
+
${c.bold("EXAMPLES:")}
|
|
165
|
+
hook-checktasks install ${c.dim("# Smart install")}
|
|
166
|
+
hook-checktasks install --global ${c.dim("# Global install")}
|
|
167
|
+
hook-checktasks install ~/my-project ${c.dim("# Specific project")}
|
|
168
|
+
hook-checktasks status ${c.dim("# Check what's installed")}
|
|
169
|
+
|
|
170
|
+
${c.bold("GLOBAL INSTALL:")} ${c.dim("(to use without bunx)")}
|
|
171
|
+
bun add -g ${PACKAGE_NAME}
|
|
172
|
+
`);
|
|
173
|
+
}
|
|
174
|
+
function isGitRepo(path) {
|
|
175
|
+
return existsSync2(join2(path, ".git"));
|
|
176
|
+
}
|
|
177
|
+
function getSettingsPath(targetPath) {
|
|
178
|
+
if (targetPath === "global") {
|
|
179
|
+
return join2(homedir2(), ".claude", "settings.json");
|
|
180
|
+
}
|
|
181
|
+
return join2(targetPath, ".claude", "settings.json");
|
|
182
|
+
}
|
|
183
|
+
function readSettings(path) {
|
|
184
|
+
if (!existsSync2(path))
|
|
185
|
+
return {};
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
188
|
+
} catch {
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function writeSettings(path, settings) {
|
|
193
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
194
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + `
|
|
195
|
+
`);
|
|
196
|
+
}
|
|
197
|
+
function getHookCommand() {
|
|
198
|
+
return `bunx ${PACKAGE_NAME} run`;
|
|
199
|
+
}
|
|
200
|
+
function hookExists(settings) {
|
|
201
|
+
const hooks = settings.hooks;
|
|
202
|
+
if (!hooks?.Stop)
|
|
203
|
+
return false;
|
|
204
|
+
const stopHooks = hooks.Stop;
|
|
205
|
+
return stopHooks.some((group) => group.hooks?.some((h) => h.command?.includes(PACKAGE_NAME)));
|
|
206
|
+
}
|
|
207
|
+
function addHook(settings) {
|
|
208
|
+
const hookConfig = {
|
|
209
|
+
type: "command",
|
|
210
|
+
command: getHookCommand(),
|
|
211
|
+
timeout: 120
|
|
212
|
+
};
|
|
213
|
+
if (!settings.hooks)
|
|
214
|
+
settings.hooks = {};
|
|
215
|
+
const hooks = settings.hooks;
|
|
216
|
+
if (!hooks.Stop) {
|
|
217
|
+
hooks.Stop = [{ hooks: [hookConfig] }];
|
|
218
|
+
} else {
|
|
219
|
+
const stopHooks = hooks.Stop;
|
|
220
|
+
if (stopHooks[0]?.hooks) {
|
|
221
|
+
stopHooks[0].hooks.push(hookConfig);
|
|
222
|
+
} else {
|
|
223
|
+
stopHooks.push({ hooks: [hookConfig] });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return settings;
|
|
227
|
+
}
|
|
228
|
+
function removeHook(settings) {
|
|
229
|
+
const hooks = settings.hooks;
|
|
230
|
+
if (!hooks?.Stop)
|
|
231
|
+
return settings;
|
|
232
|
+
const stopHooks = hooks.Stop;
|
|
233
|
+
for (const group of stopHooks) {
|
|
234
|
+
if (group.hooks) {
|
|
235
|
+
group.hooks = group.hooks.filter((h) => !h.command?.includes(PACKAGE_NAME));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
hooks.Stop = stopHooks.filter((g) => g.hooks && g.hooks.length > 0);
|
|
239
|
+
if (hooks.Stop.length === 0)
|
|
240
|
+
delete hooks.Stop;
|
|
241
|
+
return settings;
|
|
242
|
+
}
|
|
243
|
+
async function prompt(question) {
|
|
244
|
+
const rl = readline.createInterface({
|
|
245
|
+
input: process.stdin,
|
|
246
|
+
output: process.stdout
|
|
247
|
+
});
|
|
248
|
+
return new Promise((resolve2) => {
|
|
249
|
+
rl.question(question, (answer) => {
|
|
250
|
+
rl.close();
|
|
251
|
+
resolve2(answer.trim());
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async function resolveTarget(args) {
|
|
256
|
+
if (args.includes("--global") || args.includes("-g")) {
|
|
257
|
+
return { path: "global", label: "global (~/.claude/settings.json)" };
|
|
258
|
+
}
|
|
259
|
+
const pathArg = args.find((a) => !a.startsWith("-"));
|
|
260
|
+
if (pathArg) {
|
|
261
|
+
const fullPath = resolve(pathArg);
|
|
262
|
+
if (!existsSync2(fullPath)) {
|
|
263
|
+
console.log(c.red("\u2717"), `Path does not exist: ${fullPath}`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return { path: fullPath, label: `project (${fullPath})` };
|
|
267
|
+
}
|
|
268
|
+
const cwd = process.cwd();
|
|
269
|
+
if (isGitRepo(cwd)) {
|
|
270
|
+
console.log(c.green("\u2713"), `Detected git repo: ${c.cyan(cwd)}`);
|
|
271
|
+
return { path: cwd, label: `project (${cwd})` };
|
|
272
|
+
}
|
|
273
|
+
console.log(c.yellow("!"), `Not in a git repository
|
|
274
|
+
`);
|
|
275
|
+
console.log(`Where would you like to install?
|
|
276
|
+
`);
|
|
277
|
+
console.log(" 1. Global", c.dim("(~/.claude/settings.json)"));
|
|
278
|
+
console.log(` 2. Enter a path
|
|
279
|
+
`);
|
|
280
|
+
const choice = await prompt("Choice (1/2): ");
|
|
281
|
+
if (choice === "1") {
|
|
282
|
+
return { path: "global", label: "global (~/.claude/settings.json)" };
|
|
283
|
+
} else if (choice === "2") {
|
|
284
|
+
const inputPath = await prompt("Path: ");
|
|
285
|
+
if (!inputPath) {
|
|
286
|
+
console.log(c.red("\u2717"), "No path provided");
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const fullPath = resolve(inputPath);
|
|
290
|
+
if (!existsSync2(fullPath)) {
|
|
291
|
+
console.log(c.red("\u2717"), `Path does not exist: ${fullPath}`);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
return { path: fullPath, label: `project (${fullPath})` };
|
|
295
|
+
} else {
|
|
296
|
+
console.log(c.red("\u2717"), "Invalid choice");
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async function install(args) {
|
|
301
|
+
console.log(`
|
|
302
|
+
${c.bold("hook-checktasks install")}
|
|
303
|
+
`);
|
|
304
|
+
const target = await resolveTarget(args);
|
|
305
|
+
if (!target)
|
|
306
|
+
return;
|
|
307
|
+
const settingsPath = getSettingsPath(target.path);
|
|
308
|
+
const settings = readSettings(settingsPath);
|
|
309
|
+
if (hookExists(settings)) {
|
|
310
|
+
console.log(c.yellow("!"), `Already installed in ${target.label}`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const updated = addHook(settings);
|
|
314
|
+
writeSettings(settingsPath, updated);
|
|
315
|
+
console.log(c.green("\u2713"), `Installed to ${target.label}
|
|
316
|
+
`);
|
|
317
|
+
console.log(c.bold("Usage:"));
|
|
318
|
+
console.log(` CLAUDE_CODE_TASK_LIST_ID=myproject-dev claude
|
|
319
|
+
`);
|
|
320
|
+
}
|
|
321
|
+
async function uninstall(args) {
|
|
322
|
+
console.log(`
|
|
323
|
+
${c.bold("hook-checktasks uninstall")}
|
|
324
|
+
`);
|
|
325
|
+
const target = await resolveTarget(args);
|
|
326
|
+
if (!target)
|
|
327
|
+
return;
|
|
328
|
+
const settingsPath = getSettingsPath(target.path);
|
|
329
|
+
if (!existsSync2(settingsPath)) {
|
|
330
|
+
console.log(c.yellow("!"), `No settings file at ${settingsPath}`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const settings = readSettings(settingsPath);
|
|
334
|
+
if (!hookExists(settings)) {
|
|
335
|
+
console.log(c.yellow("!"), `Hook not found in ${target.label}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const updated = removeHook(settings);
|
|
339
|
+
writeSettings(settingsPath, updated);
|
|
340
|
+
console.log(c.green("\u2713"), `Removed from ${target.label}`);
|
|
341
|
+
}
|
|
342
|
+
function status() {
|
|
343
|
+
console.log(`
|
|
344
|
+
${c.bold("hook-checktasks status")}
|
|
345
|
+
`);
|
|
346
|
+
const globalPath = getSettingsPath("global");
|
|
347
|
+
const globalSettings = readSettings(globalPath);
|
|
348
|
+
const globalInstalled = hookExists(globalSettings);
|
|
349
|
+
console.log(globalInstalled ? c.green("\u2713") : c.red("\u2717"), "Global:", globalInstalled ? "Installed" : "Not installed", c.dim(`(${globalPath})`));
|
|
350
|
+
const cwd = process.cwd();
|
|
351
|
+
const projectPath = getSettingsPath(cwd);
|
|
352
|
+
if (isGitRepo(cwd)) {
|
|
353
|
+
const projectSettings = readSettings(projectPath);
|
|
354
|
+
const projectInstalled = hookExists(projectSettings);
|
|
355
|
+
console.log(projectInstalled ? c.green("\u2713") : c.red("\u2717"), "Project:", projectInstalled ? "Installed" : "Not installed", c.dim(`(${projectPath})`));
|
|
356
|
+
} else {
|
|
357
|
+
console.log(c.dim("\xB7"), "Project:", c.dim("Not in a git repo"));
|
|
358
|
+
}
|
|
359
|
+
console.log();
|
|
360
|
+
console.log(c.bold("Environment:"));
|
|
361
|
+
const taskListId = process.env.CLAUDE_CODE_TASK_LIST_ID;
|
|
362
|
+
const keywords = process.env.CHECK_TASKS_KEYWORDS;
|
|
363
|
+
const disabled = process.env.CHECK_TASKS_DISABLED === "1";
|
|
364
|
+
console.log(" CLAUDE_CODE_TASK_LIST_ID:", taskListId || c.yellow("(not set)"));
|
|
365
|
+
console.log(" CHECK_TASKS_KEYWORDS:", keywords || c.dim("dev (default)"));
|
|
366
|
+
console.log(" CHECK_TASKS_DISABLED:", disabled ? c.yellow("yes") : "no");
|
|
367
|
+
console.log();
|
|
368
|
+
}
|
|
369
|
+
var args = process.argv.slice(2);
|
|
370
|
+
var command = args[0];
|
|
371
|
+
var commandArgs = args.slice(1);
|
|
372
|
+
switch (command) {
|
|
373
|
+
case "install":
|
|
374
|
+
install(commandArgs);
|
|
375
|
+
break;
|
|
376
|
+
case "uninstall":
|
|
377
|
+
uninstall(commandArgs);
|
|
378
|
+
break;
|
|
379
|
+
case "run":
|
|
380
|
+
Promise.resolve().then(() => (init_hook(), exports_hook)).then((m) => m.run());
|
|
381
|
+
break;
|
|
382
|
+
case "status":
|
|
383
|
+
status();
|
|
384
|
+
break;
|
|
385
|
+
case "--help":
|
|
386
|
+
case "-h":
|
|
387
|
+
case undefined:
|
|
388
|
+
printUsage();
|
|
389
|
+
break;
|
|
390
|
+
default:
|
|
391
|
+
console.error(c.red(`Unknown command: ${command}`));
|
|
392
|
+
printUsage();
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __export = (target, all) => {
|
|
5
|
+
for (var name in all)
|
|
6
|
+
__defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true,
|
|
9
|
+
configurable: true,
|
|
10
|
+
set: (newValue) => all[name] = () => newValue
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
14
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
// src/hook.ts
|
|
17
|
+
import { readdirSync, readFileSync, existsSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
function readStdinJson() {
|
|
21
|
+
try {
|
|
22
|
+
const stdin = readFileSync(0, "utf-8");
|
|
23
|
+
return JSON.parse(stdin);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function getSessionName(transcriptPath) {
|
|
29
|
+
if (!existsSync(transcriptPath))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const content = readFileSync(transcriptPath, "utf-8");
|
|
33
|
+
let lastTitle = null;
|
|
34
|
+
let searchStart = 0;
|
|
35
|
+
while (true) {
|
|
36
|
+
const titleIndex = content.indexOf('"custom-title"', searchStart);
|
|
37
|
+
if (titleIndex === -1)
|
|
38
|
+
break;
|
|
39
|
+
const lineStart = content.lastIndexOf(`
|
|
40
|
+
`, titleIndex) + 1;
|
|
41
|
+
const lineEnd = content.indexOf(`
|
|
42
|
+
`, titleIndex);
|
|
43
|
+
const line = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
|
|
44
|
+
try {
|
|
45
|
+
const entry = JSON.parse(line);
|
|
46
|
+
if (entry.type === "custom-title" && entry.customTitle) {
|
|
47
|
+
lastTitle = entry.customTitle;
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
searchStart = titleIndex + 1;
|
|
51
|
+
}
|
|
52
|
+
return lastTitle;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function approve() {
|
|
58
|
+
console.log(JSON.stringify({ decision: "approve" }));
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
function block(reason) {
|
|
62
|
+
console.log(JSON.stringify({ decision: "block", reason }));
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
function run() {
|
|
66
|
+
if (process.env.CHECK_TASKS_DISABLED === "1") {
|
|
67
|
+
approve();
|
|
68
|
+
}
|
|
69
|
+
const taskListId = process.env.CLAUDE_CODE_TASK_LIST_ID;
|
|
70
|
+
if (!taskListId) {
|
|
71
|
+
approve();
|
|
72
|
+
}
|
|
73
|
+
const hookInput = readStdinJson();
|
|
74
|
+
let sessionName = null;
|
|
75
|
+
if (hookInput?.transcript_path) {
|
|
76
|
+
sessionName = getSessionName(hookInput.transcript_path);
|
|
77
|
+
}
|
|
78
|
+
const nameToCheck = sessionName || taskListId || "";
|
|
79
|
+
const keywords = (process.env.CHECK_TASKS_KEYWORDS || "dev").split(",").map((k) => k.trim().toLowerCase()).filter(Boolean);
|
|
80
|
+
const matchesKeyword = keywords.some((keyword) => nameToCheck.toLowerCase().includes(keyword));
|
|
81
|
+
if (!matchesKeyword) {
|
|
82
|
+
approve();
|
|
83
|
+
}
|
|
84
|
+
const tasksDir = join(homedir(), ".claude", "tasks", taskListId);
|
|
85
|
+
if (!existsSync(tasksDir)) {
|
|
86
|
+
approve();
|
|
87
|
+
}
|
|
88
|
+
const taskFiles = readdirSync(tasksDir).filter((f) => f.endsWith(".json"));
|
|
89
|
+
if (taskFiles.length === 0) {
|
|
90
|
+
approve();
|
|
91
|
+
}
|
|
92
|
+
const tasks = taskFiles.map((file) => {
|
|
93
|
+
const content = readFileSync(join(tasksDir, file), "utf-8");
|
|
94
|
+
return JSON.parse(content);
|
|
95
|
+
});
|
|
96
|
+
const pending = tasks.filter((t) => t.status === "pending");
|
|
97
|
+
const inProgress = tasks.filter((t) => t.status === "in_progress");
|
|
98
|
+
const completed = tasks.filter((t) => t.status === "completed");
|
|
99
|
+
const remainingCount = pending.length + inProgress.length;
|
|
100
|
+
if (remainingCount > 0) {
|
|
101
|
+
const nextTasks = pending.slice(0, 3).map((t) => `- ${t.subject}`).join(`
|
|
102
|
+
`);
|
|
103
|
+
const prompt = `
|
|
104
|
+
STOP BLOCKED: You have ${remainingCount} tasks remaining (${pending.length} pending, ${inProgress.length} in progress, ${completed.length} completed).
|
|
105
|
+
|
|
106
|
+
You MUST continue working. Do NOT stop until all tasks are completed.
|
|
107
|
+
|
|
108
|
+
Next pending tasks:
|
|
109
|
+
${nextTasks}
|
|
110
|
+
${pending.length > 3 ? `... and ${pending.length - 3} more pending tasks` : ""}
|
|
111
|
+
|
|
112
|
+
INSTRUCTIONS:
|
|
113
|
+
1. Use TaskList to see all tasks
|
|
114
|
+
2. Pick the next pending task
|
|
115
|
+
3. Use TaskUpdate to mark it as in_progress
|
|
116
|
+
4. Complete the task
|
|
117
|
+
5. Use TaskUpdate to mark it as completed
|
|
118
|
+
6. Repeat until all tasks are done
|
|
119
|
+
|
|
120
|
+
DO NOT STOP. Continue working now.
|
|
121
|
+
`.trim();
|
|
122
|
+
block(prompt);
|
|
123
|
+
}
|
|
124
|
+
approve();
|
|
125
|
+
}
|
|
126
|
+
if (__require.main == __require.module) {
|
|
127
|
+
run();
|
|
128
|
+
}
|
|
129
|
+
export {
|
|
130
|
+
run
|
|
131
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasnaxyz/hook-checktasks",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Claude Code hook that prevents stopping when there are pending tasks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hook-checktasks": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/hook.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/hook.js",
|
|
13
|
+
"types": "./dist/hook.d.ts"
|
|
14
|
+
},
|
|
15
|
+
"./cli": {
|
|
16
|
+
"import": "./dist/cli.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "bun build ./src/cli.ts ./src/hook.ts --outdir ./dist --target node",
|
|
25
|
+
"prepublishOnly": "bun run build",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"claude-code",
|
|
30
|
+
"claude",
|
|
31
|
+
"hook",
|
|
32
|
+
"tasks",
|
|
33
|
+
"automation",
|
|
34
|
+
"cli"
|
|
35
|
+
],
|
|
36
|
+
"author": "Hasna",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/hasnaxyz/hook-checktasks.git"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "restricted",
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18",
|
|
48
|
+
"bun": ">=1.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/bun": "latest",
|
|
52
|
+
"@types/node": "^20",
|
|
53
|
+
"typescript": "^5.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|