chq 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,931 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/index.ts
5
+ import { Command as Command13 } from "commander";
6
+
7
+ // src/commands/init.ts
8
+ import { Command } from "commander";
9
+ import { existsSync as existsSync2 } from "node:fs";
10
+ import { join as join2 } from "node:path";
11
+
12
+ // src/project.ts
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { homedir } from "node:os";
16
+ import { execSync } from "node:child_process";
17
+
18
+ // src/types.ts
19
+ import { z } from "zod/v4";
20
+ var TaskStatus = z.enum(["pending", "running", "done", "failed"]);
21
+ var Task = z.object({
22
+ id: z.string(),
23
+ title: z.string(),
24
+ description: z.string(),
25
+ repo: z.string(),
26
+ blockedBy: z.array(z.string()).default([]),
27
+ status: TaskStatus.default("pending")
28
+ });
29
+ var ChqConfig = z.object({
30
+ name: z.string(),
31
+ description: z.string().default(""),
32
+ createdAt: z.string(),
33
+ tasks: z.array(Task).default([])
34
+ });
35
+ var GlobalConfig = z.object({
36
+ chqHome: z.string().optional(),
37
+ pollInterval: z.number().optional()
38
+ });
39
+
40
+ // src/project.ts
41
+ var GLOBAL_CONFIG_DIR = join(homedir(), ".config", "chq");
42
+ var GLOBAL_CONFIG_PATH = join(GLOBAL_CONFIG_DIR, "config.json");
43
+ var _globalConfig;
44
+ function loadGlobalConfig() {
45
+ if (_globalConfig)
46
+ return _globalConfig;
47
+ if (!existsSync(GLOBAL_CONFIG_PATH)) {
48
+ _globalConfig = {};
49
+ return _globalConfig;
50
+ }
51
+ try {
52
+ const raw = readFileSync(GLOBAL_CONFIG_PATH, "utf-8");
53
+ _globalConfig = GlobalConfig.parse(JSON.parse(raw));
54
+ return _globalConfig;
55
+ } catch {
56
+ _globalConfig = {};
57
+ return _globalConfig;
58
+ }
59
+ }
60
+ function saveGlobalConfig(config) {
61
+ mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
62
+ writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2) + `
63
+ `);
64
+ _globalConfig = config;
65
+ }
66
+ function getChqHome() {
67
+ const config = loadGlobalConfig();
68
+ if (config.chqHome) {
69
+ return config.chqHome.replace(/^~/, homedir());
70
+ }
71
+ return join(homedir(), "chq");
72
+ }
73
+ function getPollInterval() {
74
+ const envVal = process.env["CHQ_POLL_INTERVAL"];
75
+ if (envVal)
76
+ return parseInt(envVal, 10);
77
+ const config = loadGlobalConfig();
78
+ return config.pollInterval ?? 30;
79
+ }
80
+ function resolveProject(cwd) {
81
+ const chqHome = getChqHome();
82
+ if (cwd.startsWith(chqHome)) {
83
+ const relative = cwd.slice(chqHome.length + 1);
84
+ const projectName = relative.split("/")[0];
85
+ if (projectName) {
86
+ return join(chqHome, projectName);
87
+ }
88
+ }
89
+ if (existsSync(join(cwd, "chq.json"))) {
90
+ return cwd;
91
+ }
92
+ throw new Error(`Not inside a chq project. Run "chq init <name>" first.`);
93
+ }
94
+ function loadConfig(projectDir) {
95
+ const configPath = join(projectDir, "chq.json");
96
+ if (!existsSync(configPath)) {
97
+ throw new Error(`chq.json not found in ${projectDir}`);
98
+ }
99
+ const raw = readFileSync(configPath, "utf-8");
100
+ return ChqConfig.parse(JSON.parse(raw));
101
+ }
102
+ function saveConfig(projectDir, config) {
103
+ const configPath = join(projectDir, "chq.json");
104
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + `
105
+ `);
106
+ }
107
+ function ensureProjectDir(projectDir) {
108
+ mkdirSync(projectDir, { recursive: true });
109
+ }
110
+ function resolveRepoPath(repo) {
111
+ try {
112
+ const result = execSync(`ghq list --full-path`, { encoding: "utf-8" });
113
+ const lines = result.trim().split(`
114
+ `);
115
+ const match = lines.find((line) => line.endsWith(repo) || line.includes(`/${repo}`));
116
+ if (!match) {
117
+ throw new Error(`Repository ${repo} not found via ghq. Run: ghq get ${repo}`);
118
+ }
119
+ return match.trim();
120
+ } catch (e) {
121
+ if (e instanceof Error && e.message.includes("not found via ghq")) {
122
+ throw e;
123
+ }
124
+ throw new Error(`Failed to resolve repo path for ${repo}. Is ghq installed?`);
125
+ }
126
+ }
127
+
128
+ // src/commands/init.ts
129
+ function createInitCommand() {
130
+ return new Command("init").description("Initialize a new chq project").argument("<project-name>", "Name of the project").action((projectName) => {
131
+ const projectDir = join2(getChqHome(), projectName);
132
+ if (existsSync2(join2(projectDir, "chq.json"))) {
133
+ console.error(`Project "${projectName}" already exists at ${projectDir}`);
134
+ process.exit(1);
135
+ }
136
+ ensureProjectDir(projectDir);
137
+ const config = {
138
+ name: projectName,
139
+ description: "",
140
+ createdAt: new Date().toISOString(),
141
+ tasks: []
142
+ };
143
+ saveConfig(projectDir, config);
144
+ console.log(`Created project "${projectName}" at ${projectDir}`);
145
+ console.log(`Next: cd ${projectDir} && chq plan "<goal>"`);
146
+ });
147
+ }
148
+
149
+ // src/commands/setup.ts
150
+ import { Command as Command2 } from "commander";
151
+ import { existsSync as existsSync3, writeFileSync as writeFileSync2 } from "node:fs";
152
+ import { join as join3 } from "node:path";
153
+ import { execSync as execSync2 } from "node:child_process";
154
+
155
+ // src/topo-sort.ts
156
+ function topoSort(tasks) {
157
+ const taskMap = new Map;
158
+ for (const task of tasks) {
159
+ taskMap.set(task.id, task);
160
+ }
161
+ const visited = new Set;
162
+ const inStack = new Set;
163
+ const result = [];
164
+ function visit(id, path) {
165
+ if (inStack.has(id)) {
166
+ const cycle = [...path.slice(path.indexOf(id)), id].join(" -> ");
167
+ throw new Error(`Circular dependency detected: ${cycle}`);
168
+ }
169
+ if (visited.has(id))
170
+ return;
171
+ inStack.add(id);
172
+ path.push(id);
173
+ const task = taskMap.get(id);
174
+ if (!task) {
175
+ throw new Error(`Unknown task ID referenced in blockedBy: ${id}`);
176
+ }
177
+ for (const dep of task.blockedBy) {
178
+ visit(dep, path);
179
+ }
180
+ inStack.delete(id);
181
+ path.pop();
182
+ visited.add(id);
183
+ result.push(task);
184
+ }
185
+ for (const task of tasks) {
186
+ visit(task.id, []);
187
+ }
188
+ return result;
189
+ }
190
+
191
+ // src/commands/setup.ts
192
+ function createSetupCommand() {
193
+ return new Command2("setup").description("Create worktrees and generate CLAUDE.md").action(() => {
194
+ const projectDir = resolveProject(process.cwd());
195
+ const config = loadConfig(projectDir);
196
+ if (config.tasks.length === 0) {
197
+ console.error("Error: No tasks defined in chq.json. Run chq:plan first.");
198
+ process.exit(1);
199
+ }
200
+ const sorted = topoSort(config.tasks);
201
+ const taskMap = new Map(config.tasks.map((t) => [t.id, t]));
202
+ for (const task of sorted) {
203
+ const repoPath = resolveRepoPath(task.repo);
204
+ const branchName = `chq/${config.name}/${task.id}`;
205
+ const worktreePath = join3(projectDir, `sub-${task.id}`);
206
+ if (existsSync3(worktreePath)) {
207
+ console.log(`Skipping ${task.id}: worktree already exists`);
208
+ continue;
209
+ }
210
+ let baseBranch = "main";
211
+ const sameRepoDep = task.blockedBy.find((depId) => {
212
+ const dep = taskMap.get(depId);
213
+ return dep && dep.repo === task.repo;
214
+ });
215
+ if (sameRepoDep) {
216
+ baseBranch = `chq/${config.name}/${sameRepoDep}`;
217
+ }
218
+ try {
219
+ const cmd = `git -C ${repoPath} worktree add ${worktreePath} -b ${branchName} ${baseBranch}`;
220
+ execSync2(cmd, { stdio: "inherit" });
221
+ console.log(`Created worktree for ${task.id}: ${worktreePath}`);
222
+ } catch {
223
+ console.error(`Failed to create worktree for ${task.id}`);
224
+ process.exit(1);
225
+ }
226
+ }
227
+ const taskRows = config.tasks.map((t) => {
228
+ const deps = t.blockedBy.length > 0 ? t.blockedBy.join(",") : "-";
229
+ return `| ${t.id} | ${t.title} | ${t.repo} | ${deps} | ${t.status} |`;
230
+ });
231
+ const claudeMd = `# ${config.name}
232
+
233
+ ${config.description}
234
+
235
+ ## タスク一覧
236
+
237
+ | ID | タイトル | リポジトリ | 依存 | ステータス |
238
+ |----|---------|-----------|------|-----------|
239
+ ${taskRows.join(`
240
+ `)}
241
+ `;
242
+ writeFileSync2(join3(projectDir, "CLAUDE.md"), claudeMd);
243
+ console.log(`
244
+ Generated CLAUDE.md`);
245
+ console.log("Setup complete. Run: chq dispatch");
246
+ });
247
+ }
248
+
249
+ // src/commands/status.ts
250
+ import { Command as Command3 } from "commander";
251
+ import { execSync as execSync3 } from "node:child_process";
252
+ function getPrStatus(repo, branchName) {
253
+ try {
254
+ const result = execSync3(`gh pr list --head ${branchName} -R ${repo} --json state --jq '.[0].state'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
255
+ if (result === "OPEN")
256
+ return "Open";
257
+ if (result === "MERGED")
258
+ return "Merged";
259
+ if (result === "CLOSED")
260
+ return "Closed";
261
+ return "-";
262
+ } catch {
263
+ return "-";
264
+ }
265
+ }
266
+ function getStatusIcon(task, allTasks) {
267
+ switch (task.status) {
268
+ case "done":
269
+ return "✅ done";
270
+ case "running":
271
+ return "\uD83D\uDD04 running";
272
+ case "failed":
273
+ return "❌ failed";
274
+ case "pending": {
275
+ const allDepsDone = task.blockedBy.every((depId) => {
276
+ const dep = allTasks.get(depId);
277
+ return dep?.status === "done";
278
+ });
279
+ return allDepsDone ? "⏳ ready" : "\uD83D\uDD12 pending";
280
+ }
281
+ }
282
+ }
283
+ function printStatus(projectDir) {
284
+ const config = loadConfig(projectDir);
285
+ const taskMap = new Map(config.tasks.map((t) => [t.id, t]));
286
+ console.log(`Project: ${config.name}`);
287
+ if (config.description) {
288
+ console.log(`Description: ${config.description}`);
289
+ }
290
+ console.log();
291
+ const headers = ["ID", "Title", "Repo", "Blocked By", "Status", "PR"];
292
+ const rows = config.tasks.map((task) => {
293
+ const branchName = `chq/${config.name}/${task.id}`;
294
+ const pr = task.status === "pending" ? "-" : getPrStatus(task.repo, branchName);
295
+ const deps = task.blockedBy.length > 0 ? task.blockedBy.join(",") : "-";
296
+ return [task.id, task.title, task.repo, deps, getStatusIcon(task, taskMap), pr];
297
+ });
298
+ const colWidths = headers.map((h, i) => {
299
+ const maxRow = Math.max(...rows.map((r) => (r[i] ?? "").length));
300
+ return Math.max(h.length, maxRow);
301
+ });
302
+ const formatRow = (cols) => " " + cols.map((c, i) => c.padEnd(colWidths[i])).join(" ");
303
+ console.log(formatRow(headers));
304
+ for (const row of rows) {
305
+ console.log(formatRow(row));
306
+ }
307
+ }
308
+ function createStatusCommand() {
309
+ return new Command3("status").description("Show project task status").action(() => {
310
+ const projectDir = resolveProject(process.cwd());
311
+ printStatus(projectDir);
312
+ });
313
+ }
314
+
315
+ // src/commands/dispatch.ts
316
+ import { Command as Command4 } from "commander";
317
+ import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync2 } from "node:fs";
318
+ import { join as join5 } from "node:path";
319
+ import { execSync as execSync4 } from "node:child_process";
320
+
321
+ // src/prompts.ts
322
+ import { readFileSync as readFileSync2, existsSync as existsSync4 } from "node:fs";
323
+ import { join as join4 } from "node:path";
324
+ var PROMPTS_DIR = join4(GLOBAL_CONFIG_DIR, "prompts");
325
+ function loadCustomPrompt(name) {
326
+ const filePath = join4(PROMPTS_DIR, `${name}.md`);
327
+ if (!existsSync4(filePath))
328
+ return null;
329
+ return readFileSync2(filePath, "utf-8");
330
+ }
331
+ function renderTemplate(template, vars) {
332
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
333
+ }
334
+ function computeSolveVars(config, task) {
335
+ const taskMap = new Map(config.tasks.map((t) => [t.id, t]));
336
+ let baseBranch = "main";
337
+ const sameRepoDep = task.blockedBy.find((depId) => {
338
+ const dep = taskMap.get(depId);
339
+ return dep && dep.repo === task.repo;
340
+ });
341
+ if (sameRepoDep) {
342
+ baseBranch = `chq/${config.name}/${sameRepoDep}`;
343
+ }
344
+ const crossRepoDep = task.blockedBy.some((depId) => {
345
+ const dep = taskMap.get(depId);
346
+ return dep && dep.repo !== task.repo;
347
+ });
348
+ const draftFlag = crossRepoDep ? `
349
+ --draft \\` : "";
350
+ const branchName = `chq/${config.name}/${task.id}`;
351
+ return {
352
+ projectName: config.name,
353
+ projectDescription: config.description,
354
+ taskId: task.id,
355
+ taskTitle: task.title,
356
+ taskDescription: task.description,
357
+ taskRepo: task.repo,
358
+ blockedBy: task.blockedBy.length > 0 ? task.blockedBy.join(", ") : "なし",
359
+ baseBranch,
360
+ draftFlag,
361
+ branchName
362
+ };
363
+ }
364
+ var DEFAULT_SOLVE = `# chq:solve
365
+
366
+ 引数として与えられたタスクID「{{taskId}}」の内容を実装し、PRを作成してください。
367
+
368
+ ## タスク情報
369
+
370
+ - **Project**: {{projectName}}
371
+ - **Description**: {{projectDescription}}
372
+ - **Task ID**: {{taskId}}
373
+ - **Title**: {{taskTitle}}
374
+ - **Repo**: {{taskRepo}}
375
+ - **Blocked By**: {{blockedBy}}
376
+
377
+ ## タスク詳細
378
+
379
+ {{taskDescription}}
380
+
381
+ ## 手順
382
+
383
+ ### 1. 実装
384
+
385
+ - 上記タスク詳細に記載された内容をスコープとして実装する
386
+ - このタスクのスコープ外の変更はしない
387
+ - 同一プロジェクト内の他タスクの worktree(\`sub-*\` ディレクトリ)を参照して整合性を確認してもよい
388
+
389
+ ### 2. PR作成
390
+
391
+ 以下のコマンドでPRを作成する:
392
+
393
+ \`\`\`
394
+ gh pr create \\
395
+ --base {{baseBranch}} \\
396
+ --head {{branchName}} \\
397
+ --title "{{taskTitle}}" \\{{draftFlag}}
398
+ --body "## 概要
399
+ {{taskDescription}}
400
+
401
+ ## chq
402
+ - Project: {{projectName}}
403
+ - Task: {{taskId}}
404
+ " \\
405
+ -R {{taskRepo}}
406
+ \`\`\`
407
+
408
+ ### 3. 完了通知
409
+
410
+ PR作成後、親ディレクトリの \`chq.json\` の該当タスク(id: {{taskId}})の \`status\` を \`"done"\` に更新する。
411
+ `;
412
+ var DEFAULT_PLAN = `# chq:plan
413
+
414
+ プロジェクトの目的に基づいてタスクを分解し、chq.json に書き出してください。
415
+
416
+ ## 手順
417
+
418
+ ### 1. 現状把握
419
+
420
+ - カレントディレクトリの \`chq.json\` を読み込み、プロジェクト名を確認する
421
+ - プロジェクト名: {{projectName}}
422
+ - 大目的: {{goal}}
423
+
424
+ ### 2. タスク分解
425
+
426
+ 以下の原則に従ってタスクを分解する:
427
+
428
+ - 各タスクは **1つのPR** に対応する粒度にする
429
+ - 各タスクは **1つのリポジトリ** に閉じる
430
+ - タスク間の依存関係を明確にする
431
+ - PRとして実装できない作業(手動設定、確認作業など)は含めない。ユーザーに別途伝えること
432
+
433
+ ### 3. ユーザーへの確認
434
+
435
+ 分解結果を以下の形式で表示し、ユーザーの確認を得る:
436
+
437
+ \`\`\`
438
+ タスク分解案:
439
+
440
+ t1: <title> (<repo>)
441
+ <description>
442
+ 依存: なし
443
+
444
+ t2: <title> (<repo>)
445
+ <description>
446
+ 依存: なし
447
+
448
+ t3: <title> (<repo>)
449
+ <description>
450
+ 依存: t1, t2
451
+
452
+ 依存グラフ:
453
+ t1 ─┐
454
+ ├─→ t3
455
+ t2 ─┘
456
+ \`\`\`
457
+
458
+ ### 4. chq.json への書き出し
459
+
460
+ ユーザーが承認したら、\`chq.json\` の \`tasks\` 配列にタスクを書き出す。
461
+ \`description\` にはプロジェクト全体の目的も書き出す。
462
+
463
+ 各タスクのフィールド:
464
+ - \`id\`: \`t1\`, \`t2\`, ... の連番
465
+ - \`title\`: タスクのタイトル
466
+ - \`description\`: 実装に必要な詳細説明。Claude Code がこれだけを読んで実装できる十分な情報を含めること
467
+ - \`repo\`: \`owner/repo\` 形式
468
+ - \`blockedBy\`: 依存タスクの id 配列
469
+ - \`status\`: \`"pending"\`
470
+ `;
471
+ var DEFAULT_REVIEW = `# chq:review
472
+
473
+ プロジェクト全体の整合性をレビューしてください。
474
+
475
+ ## 手順
476
+
477
+ ### 1. 全体把握
478
+
479
+ - \`chq.json\` を読み込み、全タスクの状態を確認する
480
+ - \`CLAUDE.md\` からプロジェクトの目的を把握する
481
+
482
+ ### 2. 各タスクの実装確認
483
+
484
+ - 各 \`sub-*\` ディレクトリの変更内容を確認する
485
+ - 以下の観点でレビューする:
486
+ - 各タスクがスコープ通りに実装されているか
487
+ - タスク間で矛盾する変更がないか
488
+ - リポジトリをまたいだ整合性が取れているか(API の互換性、設定値の一致など)
489
+ - 依存関係の順序でマージした場合に問題が起きないか
490
+
491
+ ### 3. レポート
492
+
493
+ レビュー結果を報告する。問題がある場合は具体的な修正提案を含める。
494
+ `;
495
+ function generateSolvePrompt(config, task) {
496
+ const vars = computeSolveVars(config, task);
497
+ const template = loadCustomPrompt("solve") ?? DEFAULT_SOLVE;
498
+ return renderTemplate(template, vars);
499
+ }
500
+ function generatePlanPrompt(projectName, goal) {
501
+ const vars = { projectName, goal };
502
+ const template = loadCustomPrompt("plan") ?? DEFAULT_PLAN;
503
+ return renderTemplate(template, vars);
504
+ }
505
+ function generateReviewPrompt() {
506
+ const template = loadCustomPrompt("review") ?? DEFAULT_REVIEW;
507
+ return renderTemplate(template, {});
508
+ }
509
+
510
+ // src/commands/dispatch.ts
511
+ function shellEscape(s) {
512
+ return "'" + s.replace(/'/g, "'\\''") + "'";
513
+ }
514
+ function dispatchTasks(projectDir) {
515
+ const config = loadConfig(projectDir);
516
+ const taskMap = new Map(config.tasks.map((t) => [t.id, t]));
517
+ let dispatched = false;
518
+ const promptsDir = join5(projectDir, ".prompts");
519
+ mkdirSync2(promptsDir, { recursive: true });
520
+ for (const task of config.tasks) {
521
+ if (task.status !== "pending")
522
+ continue;
523
+ const allDepsDone = task.blockedBy.every((depId) => {
524
+ const dep = taskMap.get(depId);
525
+ return dep?.status === "done";
526
+ });
527
+ if (!allDepsDone) {
528
+ const blockingIds = task.blockedBy.filter((depId) => {
529
+ const dep = taskMap.get(depId);
530
+ return dep?.status !== "done";
531
+ });
532
+ console.log(`Skipping ${task.id}: blocked by ${blockingIds.join(", ")}`);
533
+ continue;
534
+ }
535
+ task.status = "running";
536
+ dispatched = true;
537
+ const worktreePath = join5(projectDir, `sub-${task.id}`);
538
+ const prompt = generateSolvePrompt(config, task);
539
+ const promptPath = join5(promptsDir, `${task.id}.md`);
540
+ writeFileSync3(promptPath, prompt);
541
+ const launcherPath = join5(promptsDir, `${task.id}.sh`);
542
+ writeFileSync3(launcherPath, `#!/bin/sh
543
+ exec claude ${shellEscape(prompt)}
544
+ `, { mode: 493 });
545
+ console.log(`Dispatching ${task.id}: ${task.title} (${task.repo})`);
546
+ try {
547
+ execSync4(`wezterm cli split-pane --cwd "${worktreePath}" -- "${launcherPath}"`, { stdio: "inherit" });
548
+ execSync4(`wezterm cli set-tab-title "${task.id}: ${task.title}"`, {
549
+ stdio: "inherit"
550
+ });
551
+ } catch {
552
+ console.error(`Failed to dispatch ${task.id}. Is wezterm running?`);
553
+ task.status = "failed";
554
+ }
555
+ }
556
+ saveConfig(projectDir, config);
557
+ if (!dispatched) {
558
+ console.log("No tasks to dispatch.");
559
+ }
560
+ }
561
+ function createDispatchCommand() {
562
+ return new Command4("dispatch").description("Dispatch ready tasks to Claude Code").action(() => {
563
+ const projectDir = resolveProject(process.cwd());
564
+ dispatchTasks(projectDir);
565
+ });
566
+ }
567
+
568
+ // src/commands/watch.ts
569
+ import { Command as Command7 } from "commander";
570
+ import { execSync as execSync7 } from "node:child_process";
571
+
572
+ // src/commands/dispatch.ts
573
+ import { Command as Command5 } from "commander";
574
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync3 } from "node:fs";
575
+ import { join as join6 } from "node:path";
576
+ import { execSync as execSync5 } from "node:child_process";
577
+ function shellEscape2(s) {
578
+ return "'" + s.replace(/'/g, "'\\''") + "'";
579
+ }
580
+ function dispatchTasks2(projectDir) {
581
+ const config = loadConfig(projectDir);
582
+ const taskMap = new Map(config.tasks.map((t) => [t.id, t]));
583
+ let dispatched = false;
584
+ const promptsDir = join6(projectDir, ".prompts");
585
+ mkdirSync3(promptsDir, { recursive: true });
586
+ for (const task of config.tasks) {
587
+ if (task.status !== "pending")
588
+ continue;
589
+ const allDepsDone = task.blockedBy.every((depId) => {
590
+ const dep = taskMap.get(depId);
591
+ return dep?.status === "done";
592
+ });
593
+ if (!allDepsDone) {
594
+ const blockingIds = task.blockedBy.filter((depId) => {
595
+ const dep = taskMap.get(depId);
596
+ return dep?.status !== "done";
597
+ });
598
+ console.log(`Skipping ${task.id}: blocked by ${blockingIds.join(", ")}`);
599
+ continue;
600
+ }
601
+ task.status = "running";
602
+ dispatched = true;
603
+ const worktreePath = join6(projectDir, `sub-${task.id}`);
604
+ const prompt = generateSolvePrompt(config, task);
605
+ const promptPath = join6(promptsDir, `${task.id}.md`);
606
+ writeFileSync4(promptPath, prompt);
607
+ const launcherPath = join6(promptsDir, `${task.id}.sh`);
608
+ writeFileSync4(launcherPath, `#!/bin/sh
609
+ exec claude ${shellEscape2(prompt)}
610
+ `, { mode: 493 });
611
+ console.log(`Dispatching ${task.id}: ${task.title} (${task.repo})`);
612
+ try {
613
+ execSync5(`wezterm cli split-pane --cwd "${worktreePath}" -- "${launcherPath}"`, { stdio: "inherit" });
614
+ execSync5(`wezterm cli set-tab-title "${task.id}: ${task.title}"`, {
615
+ stdio: "inherit"
616
+ });
617
+ } catch {
618
+ console.error(`Failed to dispatch ${task.id}. Is wezterm running?`);
619
+ task.status = "failed";
620
+ }
621
+ }
622
+ saveConfig(projectDir, config);
623
+ if (!dispatched) {
624
+ console.log("No tasks to dispatch.");
625
+ }
626
+ }
627
+
628
+ // src/commands/status.ts
629
+ import { Command as Command6 } from "commander";
630
+ import { execSync as execSync6 } from "node:child_process";
631
+ function getPrStatus2(repo, branchName) {
632
+ try {
633
+ const result = execSync6(`gh pr list --head ${branchName} -R ${repo} --json state --jq '.[0].state'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
634
+ if (result === "OPEN")
635
+ return "Open";
636
+ if (result === "MERGED")
637
+ return "Merged";
638
+ if (result === "CLOSED")
639
+ return "Closed";
640
+ return "-";
641
+ } catch {
642
+ return "-";
643
+ }
644
+ }
645
+ function getStatusIcon2(task, allTasks) {
646
+ switch (task.status) {
647
+ case "done":
648
+ return "✅ done";
649
+ case "running":
650
+ return "\uD83D\uDD04 running";
651
+ case "failed":
652
+ return "❌ failed";
653
+ case "pending": {
654
+ const allDepsDone = task.blockedBy.every((depId) => {
655
+ const dep = allTasks.get(depId);
656
+ return dep?.status === "done";
657
+ });
658
+ return allDepsDone ? "⏳ ready" : "\uD83D\uDD12 pending";
659
+ }
660
+ }
661
+ }
662
+ function printStatus2(projectDir) {
663
+ const config = loadConfig(projectDir);
664
+ const taskMap = new Map(config.tasks.map((t) => [t.id, t]));
665
+ console.log(`Project: ${config.name}`);
666
+ if (config.description) {
667
+ console.log(`Description: ${config.description}`);
668
+ }
669
+ console.log();
670
+ const headers = ["ID", "Title", "Repo", "Blocked By", "Status", "PR"];
671
+ const rows = config.tasks.map((task) => {
672
+ const branchName = `chq/${config.name}/${task.id}`;
673
+ const pr = task.status === "pending" ? "-" : getPrStatus2(task.repo, branchName);
674
+ const deps = task.blockedBy.length > 0 ? task.blockedBy.join(",") : "-";
675
+ return [task.id, task.title, task.repo, deps, getStatusIcon2(task, taskMap), pr];
676
+ });
677
+ const colWidths = headers.map((h, i) => {
678
+ const maxRow = Math.max(...rows.map((r) => (r[i] ?? "").length));
679
+ return Math.max(h.length, maxRow);
680
+ });
681
+ const formatRow = (cols) => " " + cols.map((c, i) => c.padEnd(colWidths[i])).join(" ");
682
+ console.log(formatRow(headers));
683
+ for (const row of rows) {
684
+ console.log(formatRow(row));
685
+ }
686
+ }
687
+
688
+ // src/commands/watch.ts
689
+ function checkRunningTasks(projectDir) {
690
+ const config = loadConfig(projectDir);
691
+ let updated = false;
692
+ for (const task of config.tasks) {
693
+ if (task.status !== "running")
694
+ continue;
695
+ const branchName = `chq/${config.name}/${task.id}`;
696
+ try {
697
+ const result = execSync7(`gh pr list --head ${branchName} -R ${task.repo} --json state --jq '.[0].state'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
698
+ if (result === "OPEN" || result === "MERGED") {
699
+ task.status = "done";
700
+ updated = true;
701
+ console.log(`Task ${task.id} completed (PR: ${result})`);
702
+ }
703
+ } catch {}
704
+ }
705
+ if (updated) {
706
+ saveConfig(projectDir, config);
707
+ }
708
+ }
709
+ function allDone(projectDir) {
710
+ const config = loadConfig(projectDir);
711
+ return config.tasks.every((t) => t.status === "done");
712
+ }
713
+ function sleep(ms) {
714
+ return new Promise((resolve) => setTimeout(resolve, ms));
715
+ }
716
+ function createWatchCommand() {
717
+ return new Command7("watch").description("Watch and auto-dispatch tasks as dependencies complete").action(async () => {
718
+ const projectDir = resolveProject(process.cwd());
719
+ const pollInterval = getPollInterval() * 1000;
720
+ console.log(`Watching project... (poll interval: ${pollInterval / 1000}s)
721
+ `);
722
+ while (true) {
723
+ checkRunningTasks(projectDir);
724
+ if (allDone(projectDir)) {
725
+ console.log(`
726
+ All tasks completed!`);
727
+ break;
728
+ }
729
+ dispatchTasks2(projectDir);
730
+ console.log();
731
+ printStatus2(projectDir);
732
+ await sleep(pollInterval);
733
+ }
734
+ });
735
+ }
736
+
737
+ // src/commands/clean.ts
738
+ import { Command as Command8 } from "commander";
739
+ import { existsSync as existsSync5, rmSync } from "node:fs";
740
+ import { join as join7 } from "node:path";
741
+ import { execSync as execSync8 } from "node:child_process";
742
+ import { createInterface } from "node:readline";
743
+ function confirm(message) {
744
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
745
+ return new Promise((resolve) => {
746
+ rl.question(message, (answer) => {
747
+ rl.close();
748
+ resolve(answer.toLowerCase() === "y");
749
+ });
750
+ });
751
+ }
752
+ function createCleanCommand() {
753
+ return new Command8("clean").description("Remove worktrees for the current project").option("--force", "Skip confirmation prompt").option("--all", "Delete the entire project directory").action(async (opts) => {
754
+ const projectDir = resolveProject(process.cwd());
755
+ const config = loadConfig(projectDir);
756
+ if (!opts.force) {
757
+ const confirmed = await confirm(`Clean project "${config.name}"? This will remove all worktrees. (y/N) `);
758
+ if (!confirmed) {
759
+ console.log("Cancelled.");
760
+ return;
761
+ }
762
+ }
763
+ for (const task of config.tasks) {
764
+ const worktreePath = join7(projectDir, `sub-${task.id}`);
765
+ if (!existsSync5(worktreePath))
766
+ continue;
767
+ try {
768
+ const repoPath = resolveRepoPath(task.repo);
769
+ execSync8(`git -C ${repoPath} worktree remove ${worktreePath}`, {
770
+ stdio: "inherit"
771
+ });
772
+ console.log(`Removed worktree: sub-${task.id}`);
773
+ } catch {
774
+ console.error(`Failed to remove worktree: sub-${task.id}`);
775
+ }
776
+ }
777
+ if (opts.all) {
778
+ rmSync(projectDir, { recursive: true, force: true });
779
+ console.log(`Deleted project directory: ${projectDir}`);
780
+ } else {
781
+ console.log("Worktrees removed. Project directory and chq.json preserved.");
782
+ }
783
+ });
784
+ }
785
+
786
+ // src/commands/list.ts
787
+ import { Command as Command9 } from "commander";
788
+ import { existsSync as existsSync6, readdirSync, statSync } from "node:fs";
789
+ import { join as join8 } from "node:path";
790
+ function createListCommand() {
791
+ return new Command9("list").description("List all chq projects").action(() => {
792
+ const chqHome = getChqHome();
793
+ if (!existsSync6(chqHome)) {
794
+ console.log("No projects found. Run: chq init <project-name>");
795
+ return;
796
+ }
797
+ const entries = readdirSync(chqHome).filter((entry) => {
798
+ const entryPath = join8(chqHome, entry);
799
+ return statSync(entryPath).isDirectory() && existsSync6(join8(entryPath, "chq.json"));
800
+ });
801
+ if (entries.length === 0) {
802
+ console.log("No projects found. Run: chq init <project-name>");
803
+ return;
804
+ }
805
+ console.log(`Projects:
806
+ `);
807
+ for (const entry of entries) {
808
+ const projectDir = join8(chqHome, entry);
809
+ try {
810
+ const config = loadConfig(projectDir);
811
+ const total = config.tasks.length;
812
+ const done = config.tasks.filter((t) => t.status === "done").length;
813
+ const date = config.createdAt.split("T")[0];
814
+ console.log(` ${config.name} ${done}/${total} done (${date})`);
815
+ } catch {
816
+ console.log(` ${entry} (invalid chq.json)`);
817
+ }
818
+ }
819
+ });
820
+ }
821
+
822
+ // src/commands/plan.ts
823
+ import { Command as Command10 } from "commander";
824
+ import { spawnSync } from "node:child_process";
825
+ function createPlanCommand() {
826
+ return new Command10("plan").description("Launch Claude Code to plan tasks for the project").argument("<goal>", "Project goal in natural language").action((goal) => {
827
+ const projectDir = resolveProject(process.cwd());
828
+ const config = loadConfig(projectDir);
829
+ const prompt = generatePlanPrompt(config.name, goal);
830
+ console.log(`Launching Claude Code for planning "${config.name}"...
831
+ `);
832
+ const result = spawnSync("claude", [prompt], {
833
+ stdio: "inherit",
834
+ cwd: projectDir
835
+ });
836
+ process.exit(result.status ?? 0);
837
+ });
838
+ }
839
+
840
+ // src/commands/review.ts
841
+ import { Command as Command11 } from "commander";
842
+ import { spawnSync as spawnSync2 } from "node:child_process";
843
+ function createReviewCommand() {
844
+ return new Command11("review").description("Launch Claude Code to review project consistency").action(() => {
845
+ const projectDir = resolveProject(process.cwd());
846
+ const prompt = generateReviewPrompt();
847
+ console.log(`Launching Claude Code for review...
848
+ `);
849
+ const result = spawnSync2("claude", [prompt], {
850
+ stdio: "inherit",
851
+ cwd: projectDir
852
+ });
853
+ process.exit(result.status ?? 0);
854
+ });
855
+ }
856
+
857
+ // src/commands/config.ts
858
+ import { Command as Command12 } from "commander";
859
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "node:fs";
860
+ import { join as join9 } from "node:path";
861
+ var PROMPTS_DIR2 = join9(GLOBAL_CONFIG_DIR, "prompts");
862
+ function createConfigInit() {
863
+ return new Command12("init").description("Initialize global config directory with defaults").option("--force", "Overwrite existing files").action((opts) => {
864
+ mkdirSync4(PROMPTS_DIR2, { recursive: true });
865
+ const configPath = join9(GLOBAL_CONFIG_DIR, "config.json");
866
+ if (!existsSync7(configPath) || opts.force) {
867
+ saveGlobalConfig({ chqHome: "~/chq", pollInterval: 30 });
868
+ console.log(`Created ${configPath}`);
869
+ } else {
870
+ console.log(`Exists ${configPath} (use --force to overwrite)`);
871
+ }
872
+ const prompts = [
873
+ ["solve.md", DEFAULT_SOLVE],
874
+ ["plan.md", DEFAULT_PLAN],
875
+ ["review.md", DEFAULT_REVIEW]
876
+ ];
877
+ for (const [name, content] of prompts) {
878
+ const filePath = join9(PROMPTS_DIR2, name);
879
+ if (!existsSync7(filePath) || opts.force) {
880
+ writeFileSync5(filePath, content);
881
+ console.log(`Created ${filePath}`);
882
+ } else {
883
+ console.log(`Exists ${filePath} (use --force to overwrite)`);
884
+ }
885
+ }
886
+ console.log(`
887
+ Done. Edit files in ~/.config/chq/ to customize.`);
888
+ });
889
+ }
890
+ function createConfigPath() {
891
+ return new Command12("path").description("Show config directory path").action(() => {
892
+ console.log(GLOBAL_CONFIG_DIR);
893
+ });
894
+ }
895
+ function createConfigShow() {
896
+ return new Command12("show").description("Show current configuration").action(() => {
897
+ const config = loadGlobalConfig();
898
+ console.log(`Config dir: ${GLOBAL_CONFIG_DIR}`);
899
+ console.log(`chqHome: ${getChqHome()}`);
900
+ console.log(`pollInterval: ${getPollInterval()}s`);
901
+ console.log();
902
+ console.log("Prompts:");
903
+ for (const name of ["solve.md", "plan.md", "review.md"]) {
904
+ const filePath = join9(PROMPTS_DIR2, name);
905
+ const status = existsSync7(filePath) ? "custom" : "default";
906
+ console.log(` ${name} (${status})`);
907
+ }
908
+ });
909
+ }
910
+ function createConfigCommand() {
911
+ const cmd = new Command12("config").description("Manage global configuration");
912
+ cmd.addCommand(createConfigInit());
913
+ cmd.addCommand(createConfigPath());
914
+ cmd.addCommand(createConfigShow());
915
+ return cmd;
916
+ }
917
+
918
+ // src/index.ts
919
+ var program = new Command13;
920
+ program.name("chq").description("Orchestrate multi-repo tasks with Claude Code in parallel").version("0.1.0");
921
+ program.addCommand(createInitCommand());
922
+ program.addCommand(createSetupCommand());
923
+ program.addCommand(createStatusCommand());
924
+ program.addCommand(createDispatchCommand());
925
+ program.addCommand(createWatchCommand());
926
+ program.addCommand(createCleanCommand());
927
+ program.addCommand(createListCommand());
928
+ program.addCommand(createPlanCommand());
929
+ program.addCommand(createReviewCommand());
930
+ program.addCommand(createConfigCommand());
931
+ program.parse();