claude-task-worker 0.4.0 → 0.6.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 (2) hide show
  1. package/dist/index.js +153 -60
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -231,7 +231,7 @@ var DEFAULT_WORKER_CONFIG = {
231
231
  var WORKER_DEFAULTS = {
232
232
  "answer-issue-questions": {
233
233
  skill: "/claude-task-worker:answer-issue-questions",
234
- model: "sonnet",
234
+ model: "opus",
235
235
  effort: "xhigh",
236
236
  pollingIntervalSeconds: 60,
237
237
  cooldownSeconds: 0,
@@ -505,6 +505,14 @@ function isRunning(id) {
505
505
  const task = tasks.get(id);
506
506
  return task?.status === "running";
507
507
  }
508
+ function isWorktreeInUse(worktreeId) {
509
+ for (const task of tasks.values()) {
510
+ if (task.status === "running" && task.path === worktreeId) {
511
+ return true;
512
+ }
513
+ }
514
+ return false;
515
+ }
508
516
  function isWorkerAtCapacity(workerName) {
509
517
  let count = 0;
510
518
  for (const task of tasks.values()) {
@@ -951,6 +959,9 @@ function generateWorktreeName() {
951
959
  const suffix = String(randomInt(1e4)).padStart(4, "0");
952
960
  return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${suffix}`;
953
961
  }
962
+ function isGeneratedWorktreeName(name) {
963
+ return /^[a-z]+-[a-z]+-\d{4}$/.test(name);
964
+ }
954
965
 
955
966
  // src/slack.ts
956
967
  import { exec } from "node:child_process";
@@ -1087,69 +1098,152 @@ async function notifyError(workerName, repoName, error) {
1087
1098
  import { execFile as execFile3 } from "node:child_process";
1088
1099
  import { promisify as promisify3 } from "node:util";
1089
1100
  import { readdir, rm, stat } from "node:fs/promises";
1090
- import { resolve, sep } from "node:path";
1101
+ import { basename, resolve, sep } from "node:path";
1091
1102
  var execFileAsync2 = promisify3(execFile3);
1092
1103
  var WORKTREES_DIR = ".claude/worktrees";
1093
1104
  function isManagedWorktreePath(path) {
1094
1105
  return resolve(path).startsWith(resolve(WORKTREES_DIR) + sep);
1095
1106
  }
1107
+ async function pathExists(path) {
1108
+ try {
1109
+ await stat(path);
1110
+ return true;
1111
+ } catch {
1112
+ return false;
1113
+ }
1114
+ }
1096
1115
  async function forceRemoveIfExists(path) {
1097
1116
  if (!isManagedWorktreePath(path)) {
1098
1117
  console.error(`[worktree] Refusing to remove path outside ${WORKTREES_DIR}: ${path}`);
1099
1118
  return;
1100
1119
  }
1120
+ if (!await pathExists(path)) return;
1101
1121
  try {
1102
- await stat(path);
1122
+ await rm(path, { recursive: true, force: true });
1123
+ console.log(`[worktree] Force removed remaining directory: ${path}`);
1124
+ } catch (error) {
1125
+ console.error(`[worktree] Failed to remove directory ${path}:`, error);
1126
+ }
1127
+ }
1128
+ async function listWorktreeEntries() {
1129
+ const { stdout } = await execFileAsync2("git", ["worktree", "list", "--porcelain"]);
1130
+ return stdout.replace(/\r\n/g, "\n").trim().split("\n\n").filter(Boolean).map((entry) => {
1131
+ const lines = entry.split("\n");
1132
+ const worktreeLine = lines.find((l) => l.startsWith("worktree "));
1133
+ const branchLine = lines.find((l) => l.startsWith("branch refs/heads/"));
1134
+ return {
1135
+ path: worktreeLine ? worktreeLine.slice("worktree ".length) : "",
1136
+ branch: branchLine?.slice("branch refs/heads/".length),
1137
+ locked: lines.some((l) => l === "locked" || l.startsWith("locked "))
1138
+ };
1139
+ }).filter((e) => e.path !== "");
1140
+ }
1141
+ async function removeRegisteredWorktree(worktreePath) {
1142
+ try {
1143
+ await execFileAsync2("git", ["worktree", "remove", "--force", "--force", worktreePath]);
1144
+ } catch (error) {
1145
+ console.error(`[worktree] Failed to remove worktree ${worktreePath}:`, error);
1146
+ }
1147
+ await forceRemoveIfExists(worktreePath);
1148
+ }
1149
+ var PROTECTED_BRANCHES = /* @__PURE__ */ new Set(["main", "master", "develop"]);
1150
+ async function getDefaultBranchName() {
1151
+ try {
1152
+ const { stdout } = await execFileAsync2("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
1153
+ return stdout.trim().replace(/^origin\//, "");
1103
1154
  } catch {
1155
+ return void 0;
1156
+ }
1157
+ }
1158
+ async function deleteLocalBranch(branchName) {
1159
+ const defaultBranch = await getDefaultBranchName();
1160
+ if (PROTECTED_BRANCHES.has(branchName) || branchName === defaultBranch) {
1161
+ console.log(`[worktree] Skipping deletion of protected branch: ${branchName}`);
1104
1162
  return;
1105
1163
  }
1106
- await rm(path, { recursive: true, force: true });
1107
- console.log(`[worktree] Force removed remaining directory: ${path}`);
1164
+ try {
1165
+ await execFileAsync2("git", ["branch", "-D", branchName]);
1166
+ console.log(`[worktree] Deleted local branch: ${branchName}`);
1167
+ } catch (error) {
1168
+ const stderr = String(error?.stderr ?? "");
1169
+ if (/not found/i.test(stderr)) return;
1170
+ console.error(`[worktree] Failed to delete local branch ${branchName}: ${stderr.trim()}`);
1171
+ }
1108
1172
  }
1109
1173
  function getWorktreePath(worktreeId) {
1110
1174
  return `${WORKTREES_DIR}/${worktreeId}`;
1111
1175
  }
1112
1176
  async function createWorktreeFromBranch(worktreeId, baseBranch) {
1113
- const worktreePath = `${WORKTREES_DIR}/${worktreeId}`;
1177
+ const worktreePath = getWorktreePath(worktreeId);
1114
1178
  await execFileAsync2("git", ["worktree", "add", "-B", worktreeId, worktreePath, `origin/${baseBranch}`]);
1115
1179
  }
1116
1180
  async function removeWorktree(worktreeId) {
1117
- const worktreePath = `${WORKTREES_DIR}/${worktreeId}`;
1181
+ const worktreePath = getWorktreePath(worktreeId);
1182
+ let entry;
1118
1183
  try {
1119
- await execFileAsync2("git", ["worktree", "remove", "--force", worktreePath]);
1184
+ entry = (await listWorktreeEntries()).find((e) => resolve(e.path) === resolve(worktreePath));
1120
1185
  } catch (error) {
1121
- console.error(`[worktree] Failed to remove worktree ${worktreeId}:`, error);
1186
+ console.error(`[worktree] Failed to list worktrees:`, error);
1187
+ }
1188
+ if (entry) {
1189
+ await removeRegisteredWorktree(worktreePath);
1190
+ } else {
1191
+ await forceRemoveIfExists(worktreePath);
1192
+ }
1193
+ const branches = /* @__PURE__ */ new Set([entry?.branch, worktreeId]);
1194
+ for (const branch of branches) {
1195
+ if (!branch) continue;
1196
+ await deleteLocalBranch(branch);
1122
1197
  }
1123
- await forceRemoveIfExists(worktreePath);
1124
1198
  }
1125
1199
  async function removeWorktreeByBranch(branchName) {
1126
1200
  try {
1127
- const { stdout } = await execFileAsync2("git", ["worktree", "list", "--porcelain"]);
1128
- const entries = stdout.trim().split("\n\n").filter(Boolean);
1201
+ const entries = await listWorktreeEntries();
1129
1202
  for (const entry of entries) {
1130
- const branchLine = entry.split("\n").find((l) => l.startsWith("branch "));
1131
- if (!branchLine) continue;
1132
- const branch = branchLine.replace("branch refs/heads/", "");
1133
- if (branch !== branchName) continue;
1134
- const worktreeLine = entry.split("\n").find((l) => l.startsWith("worktree "));
1135
- if (!worktreeLine) continue;
1136
- const worktreePath = worktreeLine.replace("worktree ", "");
1137
- if (!isManagedWorktreePath(worktreePath)) {
1138
- console.log(`[worktree] Skipping unmanaged worktree for branch ${branchName}: ${worktreePath}`);
1203
+ if (entry.branch !== branchName) continue;
1204
+ if (!isManagedWorktreePath(entry.path)) {
1205
+ console.log(`[worktree] Skipping unmanaged worktree for branch ${branchName}: ${entry.path}`);
1139
1206
  continue;
1140
1207
  }
1141
- try {
1142
- await execFileAsync2("git", ["worktree", "remove", "--force", worktreePath]);
1143
- console.log(`[worktree] Removed worktree for branch ${branchName}: ${worktreePath}`);
1144
- } catch (error) {
1145
- console.error(`[worktree] Failed to remove worktree for branch ${branchName}:`, error);
1208
+ const worktreeId = basename(entry.path);
1209
+ if (isWorktreeInUse(worktreeId)) {
1210
+ console.log(`[worktree] Skipping worktree in use by a running task for branch ${branchName}: ${entry.path}`);
1211
+ continue;
1212
+ }
1213
+ if (entry.locked && await pathExists(entry.path)) {
1214
+ console.log(`[worktree] Skipping locked worktree for branch ${branchName}: ${entry.path}`);
1215
+ continue;
1146
1216
  }
1147
- await forceRemoveIfExists(worktreePath);
1217
+ await removeRegisteredWorktree(entry.path);
1218
+ console.log(`[worktree] Removed worktree for branch ${branchName}: ${entry.path}`);
1148
1219
  }
1149
1220
  } catch (error) {
1150
1221
  console.error(`[worktree] Failed to remove worktree for branch ${branchName}:`, error);
1151
1222
  }
1152
1223
  }
1224
+ async function removeStaleWorktrees() {
1225
+ const worktreeIds = /* @__PURE__ */ new Set();
1226
+ try {
1227
+ for (const name of await readdir(WORKTREES_DIR)) {
1228
+ if (isGeneratedWorktreeName(name)) worktreeIds.add(name);
1229
+ }
1230
+ } catch {
1231
+ }
1232
+ try {
1233
+ for (const entry of await listWorktreeEntries()) {
1234
+ if (!isManagedWorktreePath(entry.path)) continue;
1235
+ const worktreeId = basename(entry.path);
1236
+ if (isGeneratedWorktreeName(worktreeId)) worktreeIds.add(worktreeId);
1237
+ }
1238
+ } catch (error) {
1239
+ console.error(`[worktree] Failed to list worktrees:`, error);
1240
+ }
1241
+ for (const worktreeId of worktreeIds) {
1242
+ if (isWorktreeInUse(worktreeId)) continue;
1243
+ console.log(`[worktree] Removing stale worktree: ${worktreeId}`);
1244
+ await removeWorktree(worktreeId);
1245
+ }
1246
+ }
1153
1247
 
1154
1248
  // src/workers/issue-worker.ts
1155
1249
  var LABEL_TRIAGE_SCOPE = "cc-triage-scope";
@@ -1194,7 +1288,6 @@ function createIssuePollingWorker(config) {
1194
1288
  const { model, effort, skill } = getWorkerConfig(config.name);
1195
1289
  const command = skill || config.command;
1196
1290
  const parentNumber = issue.parent?.number;
1197
- let cwd;
1198
1291
  const claudeArgs = [
1199
1292
  "-p",
1200
1293
  `${command} ${issue.number}`,
@@ -1204,17 +1297,14 @@ function createIssuePollingWorker(config) {
1204
1297
  "--effort",
1205
1298
  effort
1206
1299
  ];
1300
+ let baseBranch = defaultBranch;
1207
1301
  if (parentNumber !== void 0) {
1208
- const epicBranch = `cc-epic-${parentNumber}`;
1209
- await ensureEpicBranch(epicBranch, defaultBranch);
1210
- await createWorktreeFromBranch(worktreeId, epicBranch);
1211
- cwd = getWorktreePath(worktreeId);
1212
- console.log(
1213
- `[${config.name}] #${issue.number}: created worktree ${worktreeId} from ${epicBranch} (parent #${parentNumber})`
1214
- );
1215
- } else {
1216
- claudeArgs.push("--worktree", worktreeId);
1302
+ baseBranch = `cc-epic-${parentNumber}`;
1303
+ await ensureEpicBranch(baseBranch, defaultBranch);
1217
1304
  }
1305
+ await createWorktreeFromBranch(worktreeId, baseBranch);
1306
+ const cwd = getWorktreePath(worktreeId);
1307
+ console.log(`[${config.name}] #${issue.number}: created worktree ${worktreeId} from ${baseBranch}`);
1218
1308
  run(
1219
1309
  "claude",
1220
1310
  claudeArgs,
@@ -1313,25 +1403,18 @@ function createPrPollingWorker(config) {
1313
1403
  const prUrl = `https://github.com/${owner}/${name}/pull/${pr.number}`;
1314
1404
  const hadTriageScope = pr.labels.some((l) => l.name === LABEL_TRIAGE_SCOPE2);
1315
1405
  await addLabel("pr", pr.number, LABEL_IN_PROGRESS);
1406
+ const worktreeId = generateWorktreeName();
1316
1407
  try {
1317
1408
  await removeWorktreeByBranch(pr.headRefName);
1318
- const worktreeId = generateWorktreeName();
1409
+ await deleteLocalBranch(pr.headRefName);
1319
1410
  syncDefaultBranch(defaultBranch);
1411
+ await createWorktreeFromBranch(worktreeId, defaultBranch);
1412
+ const cwd = getWorktreePath(worktreeId);
1320
1413
  const { model, effort, skill } = getWorkerConfig(config.name);
1321
1414
  const command = skill || config.command;
1322
1415
  run(
1323
1416
  "claude",
1324
- [
1325
- "-p",
1326
- `${command} ${pr.number}`,
1327
- "--dangerously-skip-permissions",
1328
- "--model",
1329
- model,
1330
- "--effort",
1331
- effort,
1332
- "--worktree",
1333
- worktreeId
1334
- ],
1417
+ ["-p", `${command} ${pr.number}`, "--dangerously-skip-permissions", "--model", model, "--effort", effort],
1335
1418
  pr.number,
1336
1419
  `PR #${pr.number} (${pr.headRefName})`,
1337
1420
  config.name,
@@ -1372,12 +1455,15 @@ function createPrPollingWorker(config) {
1372
1455
  (err) => console.error(`[${config.name}] removeWorktree failed for PR #${pr.number}: ${err}`)
1373
1456
  );
1374
1457
  }
1375
- }
1458
+ },
1459
+ cwd
1376
1460
  );
1377
1461
  } catch (err) {
1378
1462
  console.error(`[${config.name}] setup error for PR #${pr.number}: ${err}`);
1379
1463
  await removeLabel("pr", pr.number, LABEL_IN_PROGRESS).catch(() => {
1380
1464
  });
1465
+ await removeWorktree(worktreeId).catch(() => {
1466
+ });
1381
1467
  await notifyError(config.name, name, err);
1382
1468
  }
1383
1469
  }
@@ -1902,19 +1988,23 @@ if (workerType === "init") {
1902
1988
  } else if (workerType === "all") {
1903
1989
  const epicFilters = parseEpicFilters();
1904
1990
  const labelFilters = parseLabelFilters();
1905
- Promise.all([
1906
- execIssueWorker({ epicFilters, labelFilters }),
1907
- fixReviewPointWorker(),
1908
- createIssueWorker({ epicFilters, labelFilters }),
1909
- updateIssueWorker({ epicFilters, labelFilters }),
1910
- answerIssueQuestionsWorker({ epicFilters, labelFilters }),
1911
- resolveConflictWorker(),
1912
- epicIssueWorker({ epicFilters, labelFilters })
1913
- ]);
1991
+ (async () => {
1992
+ await removeStaleWorktrees();
1993
+ await Promise.all([
1994
+ execIssueWorker({ epicFilters, labelFilters }),
1995
+ fixReviewPointWorker(),
1996
+ createIssueWorker({ epicFilters, labelFilters }),
1997
+ updateIssueWorker({ epicFilters, labelFilters }),
1998
+ answerIssueQuestionsWorker({ epicFilters, labelFilters }),
1999
+ resolveConflictWorker(),
2000
+ epicIssueWorker({ epicFilters, labelFilters })
2001
+ ]);
2002
+ })();
1914
2003
  } else if (workerType === "yolo") {
1915
2004
  const epicFilters = parseEpicFilters();
1916
2005
  const labelFilters = parseLabelFilters();
1917
2006
  (async () => {
2007
+ await removeStaleWorktrees();
1918
2008
  await Promise.all([
1919
2009
  execIssueWorker({ epicFilters, labelFilters }),
1920
2010
  fixReviewPointWorker(),
@@ -1931,5 +2021,8 @@ if (workerType === "init") {
1931
2021
  } else {
1932
2022
  const epicFilters = parseEpicFilters();
1933
2023
  const labelFilters = parseLabelFilters();
1934
- WORKERS[workerType]({ epicFilters, labelFilters });
2024
+ (async () => {
2025
+ await removeStaleWorktrees();
2026
+ await WORKERS[workerType]({ epicFilters, labelFilters });
2027
+ })();
1935
2028
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-task-worker",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "CLI tool that polls GitHub Issues/PRs and delegates work to Claude CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",