techunter 0.1.5 → 0.1.7

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ```
6
6
  ╔═══════════════╗
7
- ◆═══╬ TECHUNTER ╬═══▶ Techunter v0.1.4
7
+ ◆═══╬ TECHUNTER ╬═══▶ Techunter v0.1.6
8
8
  ╚═══════════════╝ GLM-5 · z-ai · owner/repo
9
9
  ```
10
10
 
package/dist/index.js CHANGED
@@ -18,7 +18,9 @@ __export(github_exports, {
18
18
  createPR: () => createPR,
19
19
  createTask: () => createTask,
20
20
  editTask: () => editTask,
21
+ embedBaseCommit: () => embedBaseCommit,
21
22
  ensureLabels: () => ensureLabels,
23
+ extractBaseCommit: () => extractBaseCommit,
22
24
  formatGuideAsMarkdown: () => formatGuideAsMarkdown,
23
25
  getAuthenticatedUser: () => getAuthenticatedUser,
24
26
  getDefaultBranch: () => getDefaultBranch,
@@ -29,6 +31,7 @@ __export(github_exports, {
29
31
  listTasks: () => listTasks,
30
32
  listTasksForReview: () => listTasksForReview,
31
33
  markInReview: () => markInReview,
34
+ mergeWorkerIntoBase: () => mergeWorkerIntoBase,
32
35
  postComment: () => postComment,
33
36
  postGuideComment: () => postGuideComment,
34
37
  rejectTask: () => rejectTask
@@ -77,19 +80,41 @@ async function getTask(config, number) {
77
80
  const { data } = await octokit.issues.get({ owner, repo, issue_number: number });
78
81
  return parseIssue(data);
79
82
  }
80
- async function createTask(config, title, body) {
83
+ function embedBaseCommit(body, sha) {
84
+ return `${body}
85
+
86
+ ${BASE_COMMIT_MARKER}${sha} -->`;
87
+ }
88
+ function extractBaseCommit(body) {
89
+ if (!body) return null;
90
+ const match = body.match(/<!-- techunter-base:([a-f0-9]{7,40}) -->/);
91
+ return match?.[1] ?? null;
92
+ }
93
+ async function createTask(config, title, body, baseCommit) {
81
94
  const octokit = createOctokit(config.githubToken);
82
95
  const { owner, repo } = config.github;
83
96
  await ensureLabels(config);
97
+ const finalBody = baseCommit ? embedBaseCommit(body ?? "", baseCommit) : body;
84
98
  const { data } = await octokit.issues.create({
85
99
  owner,
86
100
  repo,
87
101
  title,
88
- body,
102
+ body: finalBody,
89
103
  labels: [LABEL_AVAILABLE]
90
104
  });
91
105
  return parseIssue(data);
92
106
  }
107
+ async function mergeWorkerIntoBase(config, workerBranch, baseBranch) {
108
+ const octokit = createOctokit(config.githubToken);
109
+ const { owner, repo } = config.github;
110
+ await octokit.repos.merge({
111
+ owner,
112
+ repo,
113
+ base: baseBranch,
114
+ head: workerBranch,
115
+ commit_message: `chore: merge ${workerBranch} into ${baseBranch}`
116
+ });
117
+ }
93
118
  async function claimTask(config, number, username) {
94
119
  const octokit = createOctokit(config.githubToken);
95
120
  const { owner, repo } = config.github;
@@ -308,12 +333,12 @@ async function getDefaultBranch(config) {
308
333
  const { data } = await octokit.repos.get({ owner, repo });
309
334
  return data.default_branch;
310
335
  }
311
- async function acceptTask(config, issueNumber) {
336
+ async function acceptTask(config, issueNumber, headBranch) {
312
337
  const octokit = createOctokit(config.githubToken);
313
338
  const { owner, repo } = config.github;
314
339
  const { data: prs } = await octokit.pulls.list({ owner, repo, state: "open", per_page: 100 });
315
- const pr = prs.find((p) => p.head.ref.startsWith(`task-${issueNumber}-`));
316
- if (!pr) throw new Error(`No open PR found for task #${issueNumber} (expected branch starting with task-${issueNumber}-)`);
340
+ const pr = headBranch ? prs.find((p) => p.head.ref === headBranch) : prs.find((p) => p.head.ref.startsWith(`task-${issueNumber}-`) || p.head.ref.startsWith("worker-"));
341
+ if (!pr) throw new Error(`No open PR found for task #${issueNumber}`);
317
342
  const { data: merge } = await octokit.pulls.merge({
318
343
  owner,
319
344
  repo,
@@ -323,7 +348,7 @@ async function acceptTask(config, issueNumber) {
323
348
  await closeTask(config, issueNumber);
324
349
  return { prNumber: pr.number, prUrl: pr.html_url, sha: merge.sha ?? "" };
325
350
  }
326
- var LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED, LABELS, TECHUNTER_LABELS;
351
+ var LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED, LABELS, TECHUNTER_LABELS, BASE_COMMIT_MARKER;
327
352
  var init_github = __esm({
328
353
  "src/lib/github.ts"() {
329
354
  "use strict";
@@ -338,6 +363,7 @@ var init_github = __esm({
338
363
  { name: LABEL_CHANGES_NEEDED, color: "e11d48", description: "Task needs changes" }
339
364
  ];
340
365
  TECHUNTER_LABELS = /* @__PURE__ */ new Set([LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED]);
366
+ BASE_COMMIT_MARKER = "<!-- techunter-base:";
341
367
  }
342
368
  });
343
369
 
@@ -362,6 +388,7 @@ var configSchema = z.object({
362
388
  aiModel: z.string().optional(),
363
389
  githubToken: z.string().min(1),
364
390
  githubClientId: z.string().optional(),
391
+ baseBranch: z.string().optional(),
365
392
  github: z.object({
366
393
  owner: z.string().min(1),
367
394
  repo: z.string().min(1)
@@ -406,6 +433,9 @@ function setConfig(partial) {
406
433
  if (partial.githubClientId !== void 0) {
407
434
  current["githubClientId"] = partial.githubClientId;
408
435
  }
436
+ if (partial.baseBranch !== void 0) {
437
+ current["baseBranch"] = partial.baseBranch;
438
+ }
409
439
  if (partial.taskState !== void 0) {
410
440
  current["taskState"] = {
411
441
  ...current["taskState"] ?? {},
@@ -568,6 +598,28 @@ async function stageAllAndCommit(message) {
568
598
  const branch = (await git.branch()).current;
569
599
  await git.push("origin", branch, ["--set-upstream"]);
570
600
  }
601
+ async function syncWithBase(baseBranch) {
602
+ await git.fetch("origin", baseBranch);
603
+ try {
604
+ await git.merge([`origin/${baseBranch}`, "--ff-only"]);
605
+ } catch {
606
+ await git.merge([`origin/${baseBranch}`, "-m", `chore: sync with ${baseBranch}`]);
607
+ }
608
+ }
609
+ async function getRemoteHeadSha(baseBranch) {
610
+ await git.fetch("origin", baseBranch);
611
+ return (await git.revparse([`origin/${baseBranch}`])).trim();
612
+ }
613
+ async function resetOrCreateBranch(branchName, sha) {
614
+ const branches = await git.branch();
615
+ const localExists = Object.keys(branches.branches).some((b) => b === branchName);
616
+ if (localExists) {
617
+ await git.checkout(branchName);
618
+ await git.reset(["--hard", sha]);
619
+ } else {
620
+ await git.checkoutBranch(branchName, sha);
621
+ }
622
+ }
571
623
 
572
624
  // src/lib/client.ts
573
625
  import OpenAI from "openai";
@@ -736,10 +788,12 @@ async function configCommand() {
736
788
  `));
737
789
  const currentBaseUrl = config.aiBaseUrl ?? DEFAULT_BASE_URL;
738
790
  const currentModel = config.aiModel ?? DEFAULT_MODEL;
791
+ const currentBaseBranch = config.baseBranch ?? "main";
739
792
  const field = await select2({
740
793
  message: "Which setting to change?",
741
794
  choices: [
742
795
  { name: `GitHub repo ${chalk3.dim(`${config.github.owner}/${config.github.repo}`)}`, value: "repo" },
796
+ { name: `Base branch ${chalk3.dim(currentBaseBranch)}`, value: "baseBranch" },
743
797
  { name: `AI base URL ${chalk3.dim(currentBaseUrl)}`, value: "aiBaseUrl" },
744
798
  { name: `AI model ${chalk3.dim(currentModel)}`, value: "aiModel" },
745
799
  { name: `AI API Key ${chalk3.dim("(hidden)")}`, value: "aiApiKey" },
@@ -748,7 +802,15 @@ async function configCommand() {
748
802
  ]
749
803
  });
750
804
  if (field === "cancel") return;
751
- if (field === "repo") {
805
+ if (field === "baseBranch") {
806
+ const val = await input2({ message: "Base branch name:", default: currentBaseBranch });
807
+ if (val.trim()) {
808
+ setConfig({ baseBranch: val.trim() });
809
+ console.log(chalk3.green(`
810
+ Base branch set to: ${val.trim()}
811
+ `));
812
+ }
813
+ } else if (field === "repo") {
752
814
  const owner = await input2({ message: "GitHub repo owner:", default: config.github.owner });
753
815
  const repo = await input2({ message: "GitHub repo name:", default: config.github.repo });
754
816
  setConfig({ github: { ...config.github, owner: owner.trim(), repo: repo.trim() } });
@@ -805,6 +867,7 @@ init_github();
805
867
  import chalk8 from "chalk";
806
868
  import ora4 from "ora";
807
869
  import { select as select5 } from "@inquirer/prompts";
870
+ init_github();
808
871
 
809
872
  // src/lib/markdown.ts
810
873
  import { marked } from "marked";
@@ -944,7 +1007,8 @@ async function launchClaudeCode(issue, branch) {
944
1007
  const prompt = buildClaudePrompt(issue, branch);
945
1008
  console.log(chalk5.dim("\n Launching Claude Code\u2026\n"));
946
1009
  await new Promise((resolve) => {
947
- const child = spawn("claude", [prompt], { stdio: "inherit", shell: true });
1010
+ const safePrompt = prompt.replace(/\r?\n/g, " ").replace(/"/g, "'");
1011
+ const child = spawn(`claude "${safePrompt}"`, [], { stdio: "inherit", shell: true });
948
1012
  child.on("close", () => resolve());
949
1013
  child.on("error", () => {
950
1014
  console.log(
@@ -1142,8 +1206,16 @@ async function run(_input, config) {
1142
1206
  return `Commit failed: ${err.message}`;
1143
1207
  }
1144
1208
  if (isSelfSubmit) {
1209
+ spinner = ora2("Closing issue\u2026").start();
1210
+ try {
1211
+ await closeTask(config, issueNumber);
1212
+ spinner.stop();
1213
+ } catch (err) {
1214
+ spinner.stop();
1215
+ console.error(chalk7.yellow(`Warning: failed to close issue: ${err.message}`));
1216
+ }
1145
1217
  setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
1146
- return `Task #${issueNumber} committed.
1218
+ return `Task #${issueNumber} committed and closed.
1147
1219
  Commit: "${commitMessage.trim()}"`;
1148
1220
  }
1149
1221
  spinner = ora2("Creating pull request\u2026").start();
@@ -1204,8 +1276,12 @@ async function execute(input3, config) {
1204
1276
  return `Commit failed: ${err.message}`;
1205
1277
  }
1206
1278
  if (isSelfSubmit) {
1279
+ try {
1280
+ await closeTask(config, issueNumber);
1281
+ } catch {
1282
+ }
1207
1283
  setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
1208
- return `Task #${issueNumber} committed.
1284
+ return `Task #${issueNumber} committed and closed.
1209
1285
  Commit: "${commitMessage}"`;
1210
1286
  }
1211
1287
  let prUrl;
@@ -1413,41 +1489,29 @@ Finish or submit it before claiming a new one.`;
1413
1489
  await claimTask(config, issue.number, me);
1414
1490
  spinner.stop();
1415
1491
  const workerBranch = makeWorkerBranchName(me);
1416
- spinner = ora4(`Switching to ${workerBranch}\u2026`).start();
1492
+ const taskBase = extractBaseCommit(issue.body);
1493
+ spinner = ora4(`Switching to ${workerBranch}${taskBase ? ` from ${taskBase.slice(0, 7)}` : ""}\u2026`).start();
1417
1494
  try {
1418
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1419
- spinner.stop();
1420
- if (isNewWorker) {
1421
- spinner = ora4("Pushing worker branch\u2026").start();
1422
- try {
1423
- await pushBranch(workerBranch);
1424
- spinner.stop();
1425
- } catch {
1426
- spinner.warn("Could not push worker branch");
1427
- }
1495
+ if (taskBase) {
1496
+ await resetOrCreateBranch(workerBranch, taskBase);
1497
+ } else {
1498
+ await switchToBranchOrCreate(workerBranch);
1428
1499
  }
1429
- } catch {
1430
- spinner.warn(`Could not switch to ${workerBranch}`);
1431
- }
1432
- const branch = makeBranchName(issue.number, me);
1433
- spinner = ora4(`Creating task branch ${branch}\u2026`).start();
1434
- try {
1435
- await switchToBranchOrCreate(branch);
1436
1500
  spinner.stop();
1437
- spinner = ora4("Pushing task branch\u2026").start();
1501
+ spinner = ora4("Pushing worker branch\u2026").start();
1438
1502
  try {
1439
- await pushBranch(branch);
1503
+ await pushBranch(workerBranch);
1440
1504
  spinner.stop();
1441
1505
  } catch {
1442
- spinner.warn("Could not push task branch");
1506
+ spinner.warn("Could not push worker branch");
1443
1507
  }
1444
1508
  } catch {
1445
- spinner.warn(`Could not create branch ${branch}`);
1509
+ spinner.warn(`Could not switch to ${workerBranch}`);
1446
1510
  }
1447
1511
  const baseCommit = await getCurrentCommit();
1448
1512
  setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1449
1513
  console.log(chalk8.green(`
1450
- Claimed! Branch: ${branch} (base: ${baseCommit.slice(0, 7)})
1514
+ Claimed! Branch: ${workerBranch} (base: ${baseCommit.slice(0, 7)})
1451
1515
  `));
1452
1516
  let openClaude;
1453
1517
  try {
@@ -1461,8 +1525,8 @@ Finish or submit it before claiming a new one.`;
1461
1525
  } catch {
1462
1526
  openClaude = false;
1463
1527
  }
1464
- if (openClaude) await launchClaudeCode(issue, branch);
1465
- return `Task #${issue.number} claimed. Branch: ${branch}`;
1528
+ if (openClaude) await launchClaudeCode(issue, workerBranch);
1529
+ return `Task #${issue.number} claimed. Branch: ${workerBranch}`;
1466
1530
  } catch (err) {
1467
1531
  return `Error claiming task: ${err.message}`;
1468
1532
  }
@@ -1501,28 +1565,22 @@ async function execute3(input3, config) {
1501
1565
  return `Error claiming task: ${err.message}`;
1502
1566
  }
1503
1567
  const workerBranch = makeWorkerBranchName(me);
1568
+ const taskBase = extractBaseCommit(issue.body);
1504
1569
  try {
1505
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1506
- if (isNewWorker) {
1507
- try {
1508
- await pushBranch(workerBranch);
1509
- } catch {
1510
- }
1570
+ if (taskBase) {
1571
+ await resetOrCreateBranch(workerBranch, taskBase);
1572
+ } else {
1573
+ await switchToBranchOrCreate(workerBranch);
1511
1574
  }
1512
1575
  } catch {
1513
1576
  }
1514
- const branch = makeBranchName(issueNumber, me);
1515
1577
  try {
1516
- await switchToBranchOrCreate(branch);
1517
- } catch {
1518
- }
1519
- try {
1520
- await pushBranch(branch);
1578
+ await pushBranch(workerBranch);
1521
1579
  } catch {
1522
1580
  }
1523
1581
  const baseCommit = await getCurrentCommit();
1524
1582
  setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1525
- return `Task #${issueNumber} claimed. Branch: ${branch} (base commit: ${baseCommit.slice(0, 7)})`;
1583
+ return `Task #${issueNumber} claimed. Branch: ${workerBranch} (base commit: ${baseCommit.slice(0, 7)})`;
1526
1584
  }
1527
1585
  return `Unknown action: ${action}`;
1528
1586
  }
@@ -1692,12 +1750,26 @@ async function run4(input3, config) {
1692
1750
  console.log(chalk9.yellow(` Revision error: ${err.message}`));
1693
1751
  }
1694
1752
  }
1753
+ const baseBranch = config.baseBranch ?? "main";
1754
+ let baseCommit;
1755
+ const syncSpinner = ora5(`Syncing with ${baseBranch}\u2026`).start();
1756
+ try {
1757
+ await syncWithBase(baseBranch);
1758
+ baseCommit = await getCurrentCommit();
1759
+ syncSpinner.succeed(`Synced with ${baseBranch} (base: ${baseCommit.slice(0, 7)})`);
1760
+ } catch {
1761
+ syncSpinner.warn(`Could not sync with ${baseBranch} \u2014 recording remote HEAD as base`);
1762
+ try {
1763
+ baseCommit = await getRemoteHeadSha(baseBranch);
1764
+ } catch {
1765
+ }
1766
+ }
1695
1767
  const createSpinner = ora5(`Creating "${title}"\u2026`).start();
1696
1768
  let htmlUrl;
1697
1769
  let issueNumber;
1698
1770
  let issueTitle;
1699
1771
  try {
1700
- const issue = await createTask(config, title, guide);
1772
+ const issue = await createTask(config, title, guide, baseCommit);
1701
1773
  createSpinner.stop();
1702
1774
  htmlUrl = issue.htmlUrl;
1703
1775
  issueNumber = issue.number;
@@ -1734,8 +1806,19 @@ async function execute4(input3, config) {
1734
1806
  if (feedback) {
1735
1807
  guide = await generateGuide(config, title, { feedback, previousGuide: guide });
1736
1808
  }
1809
+ const baseBranch = config.baseBranch ?? "main";
1810
+ let baseCommit;
1811
+ try {
1812
+ await syncWithBase(baseBranch);
1813
+ baseCommit = await getCurrentCommit();
1814
+ } catch {
1815
+ try {
1816
+ baseCommit = await getRemoteHeadSha(baseBranch);
1817
+ } catch {
1818
+ }
1819
+ }
1737
1820
  try {
1738
- const issue = await createTask(config, title, guide);
1821
+ const issue = await createTask(config, title, guide, baseCommit);
1739
1822
  return `Created #${issue.number} "${issue.title}" \u2014 ${issue.htmlUrl}
1740
1823
 
1741
1824
  Guide:
@@ -2136,16 +2219,40 @@ async function run10(input3, config) {
2136
2219
  }
2137
2220
  if (!confirmed) return "Cancelled.";
2138
2221
  const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2222
+ let result;
2139
2223
  try {
2140
- const result = await acceptTask(config, issueNumber);
2224
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2225
+ result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2141
2226
  spinner.succeed(`PR #${result.prNumber} merged into ${targetBranch}`);
2142
- return `Task #${issueNumber} accepted.
2143
- PR #${result.prNumber} merged \u2192 ${targetBranch}
2144
- Issue closed.`;
2145
2227
  } catch (err) {
2146
2228
  spinner.fail("Failed");
2147
2229
  return `Error: ${err.message}`;
2148
2230
  }
2231
+ const baseBranch = config.baseBranch ?? "main";
2232
+ let pushToMain;
2233
+ try {
2234
+ pushToMain = await select8({
2235
+ message: `Push ${chalk11.cyan(targetBranch)} \u2192 ${chalk11.cyan(baseBranch)}?`,
2236
+ choices: [
2237
+ { name: `Yes, push to ${baseBranch}`, value: true },
2238
+ { name: "No, keep in worker branch", value: false }
2239
+ ]
2240
+ });
2241
+ } catch {
2242
+ pushToMain = false;
2243
+ }
2244
+ if (pushToMain) {
2245
+ const mergeSpinner = ora9(`Merging ${targetBranch} \u2192 ${baseBranch}\u2026`).start();
2246
+ try {
2247
+ await mergeWorkerIntoBase(config, targetBranch, baseBranch);
2248
+ mergeSpinner.succeed(`Merged ${targetBranch} \u2192 ${baseBranch}`);
2249
+ } catch (err) {
2250
+ mergeSpinner.fail(`Could not merge to ${baseBranch}: ${err.message}`);
2251
+ }
2252
+ }
2253
+ return `Task #${issueNumber} accepted.
2254
+ PR #${result.prNumber} merged \u2192 ${targetBranch}${pushToMain ? ` \u2192 ${baseBranch}` : ""}
2255
+ Issue closed.`;
2149
2256
  }
2150
2257
  async function execute10(input3, config) {
2151
2258
  const issueNumber = input3["issue_number"];
@@ -2159,7 +2266,8 @@ async function execute10(input3, config) {
2159
2266
  const targetBranch = makeWorkerBranchName(me);
2160
2267
  const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2161
2268
  try {
2162
- const result = await acceptTask(config, issueNumber);
2269
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2270
+ const result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2163
2271
  spinner.stop();
2164
2272
  return `Task #${issueNumber} accepted.
2165
2273
  PR #${result.prNumber} merged \u2192 ${targetBranch}
@@ -2921,6 +3029,8 @@ function completer(line) {
2921
3029
  var _rl = null;
2922
3030
  function promptUser() {
2923
3031
  return new Promise((resolve) => {
3032
+ if (process.stdin.isPaused()) process.stdin.resume();
3033
+ _rl.resume();
2924
3034
  _rl.question(chalk14.cyan("You") + chalk14.dim(" \u203A "), resolve);
2925
3035
  });
2926
3036
  }
package/dist/mcp.js CHANGED
@@ -18,7 +18,9 @@ __export(github_exports, {
18
18
  createPR: () => createPR,
19
19
  createTask: () => createTask,
20
20
  editTask: () => editTask,
21
+ embedBaseCommit: () => embedBaseCommit,
21
22
  ensureLabels: () => ensureLabels,
23
+ extractBaseCommit: () => extractBaseCommit,
22
24
  formatGuideAsMarkdown: () => formatGuideAsMarkdown,
23
25
  getAuthenticatedUser: () => getAuthenticatedUser,
24
26
  getDefaultBranch: () => getDefaultBranch,
@@ -29,6 +31,7 @@ __export(github_exports, {
29
31
  listTasks: () => listTasks,
30
32
  listTasksForReview: () => listTasksForReview,
31
33
  markInReview: () => markInReview,
34
+ mergeWorkerIntoBase: () => mergeWorkerIntoBase,
32
35
  postComment: () => postComment,
33
36
  postGuideComment: () => postGuideComment,
34
37
  rejectTask: () => rejectTask
@@ -77,19 +80,41 @@ async function getTask(config, number) {
77
80
  const { data } = await octokit.issues.get({ owner, repo, issue_number: number });
78
81
  return parseIssue(data);
79
82
  }
80
- async function createTask(config, title, body) {
83
+ function embedBaseCommit(body, sha) {
84
+ return `${body}
85
+
86
+ ${BASE_COMMIT_MARKER}${sha} -->`;
87
+ }
88
+ function extractBaseCommit(body) {
89
+ if (!body) return null;
90
+ const match = body.match(/<!-- techunter-base:([a-f0-9]{7,40}) -->/);
91
+ return match?.[1] ?? null;
92
+ }
93
+ async function createTask(config, title, body, baseCommit) {
81
94
  const octokit = createOctokit(config.githubToken);
82
95
  const { owner, repo } = config.github;
83
96
  await ensureLabels(config);
97
+ const finalBody = baseCommit ? embedBaseCommit(body ?? "", baseCommit) : body;
84
98
  const { data } = await octokit.issues.create({
85
99
  owner,
86
100
  repo,
87
101
  title,
88
- body,
102
+ body: finalBody,
89
103
  labels: [LABEL_AVAILABLE]
90
104
  });
91
105
  return parseIssue(data);
92
106
  }
107
+ async function mergeWorkerIntoBase(config, workerBranch, baseBranch) {
108
+ const octokit = createOctokit(config.githubToken);
109
+ const { owner, repo } = config.github;
110
+ await octokit.repos.merge({
111
+ owner,
112
+ repo,
113
+ base: baseBranch,
114
+ head: workerBranch,
115
+ commit_message: `chore: merge ${workerBranch} into ${baseBranch}`
116
+ });
117
+ }
93
118
  async function claimTask(config, number, username) {
94
119
  const octokit = createOctokit(config.githubToken);
95
120
  const { owner, repo } = config.github;
@@ -308,12 +333,12 @@ async function getDefaultBranch(config) {
308
333
  const { data } = await octokit.repos.get({ owner, repo });
309
334
  return data.default_branch;
310
335
  }
311
- async function acceptTask(config, issueNumber) {
336
+ async function acceptTask(config, issueNumber, headBranch) {
312
337
  const octokit = createOctokit(config.githubToken);
313
338
  const { owner, repo } = config.github;
314
339
  const { data: prs } = await octokit.pulls.list({ owner, repo, state: "open", per_page: 100 });
315
- const pr = prs.find((p) => p.head.ref.startsWith(`task-${issueNumber}-`));
316
- if (!pr) throw new Error(`No open PR found for task #${issueNumber} (expected branch starting with task-${issueNumber}-)`);
340
+ const pr = headBranch ? prs.find((p) => p.head.ref === headBranch) : prs.find((p) => p.head.ref.startsWith(`task-${issueNumber}-`) || p.head.ref.startsWith("worker-"));
341
+ if (!pr) throw new Error(`No open PR found for task #${issueNumber}`);
317
342
  const { data: merge } = await octokit.pulls.merge({
318
343
  owner,
319
344
  repo,
@@ -323,7 +348,7 @@ async function acceptTask(config, issueNumber) {
323
348
  await closeTask(config, issueNumber);
324
349
  return { prNumber: pr.number, prUrl: pr.html_url, sha: merge.sha ?? "" };
325
350
  }
326
- var LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED, LABELS, TECHUNTER_LABELS;
351
+ var LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED, LABELS, TECHUNTER_LABELS, BASE_COMMIT_MARKER;
327
352
  var init_github = __esm({
328
353
  "src/lib/github.ts"() {
329
354
  "use strict";
@@ -338,6 +363,7 @@ var init_github = __esm({
338
363
  { name: LABEL_CHANGES_NEEDED, color: "e11d48", description: "Task needs changes" }
339
364
  ];
340
365
  TECHUNTER_LABELS = /* @__PURE__ */ new Set([LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED]);
366
+ BASE_COMMIT_MARKER = "<!-- techunter-base:";
341
367
  }
342
368
  });
343
369
 
@@ -370,10 +396,6 @@ async function getCurrentBranch() {
370
396
  async function pushBranch(name) {
371
397
  await git.push("origin", name, ["--set-upstream"]);
372
398
  }
373
- function makeBranchName(issueNumber, username) {
374
- const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
375
- return `task-${issueNumber}-${slug}`;
376
- }
377
399
  function makeWorkerBranchName(username) {
378
400
  const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
379
401
  return `worker-${slug}`;
@@ -486,6 +508,31 @@ async function stageAllAndCommit(message) {
486
508
  const branch = (await git.branch()).current;
487
509
  await git.push("origin", branch, ["--set-upstream"]);
488
510
  }
511
+ async function syncWithBase(baseBranch) {
512
+ await git.fetch("origin", baseBranch);
513
+ try {
514
+ await git.merge([`origin/${baseBranch}`, "--ff-only"]);
515
+ } catch {
516
+ await git.merge([`origin/${baseBranch}`, "-m", `chore: sync with ${baseBranch}`]);
517
+ }
518
+ }
519
+ async function getRemoteHeadSha(baseBranch) {
520
+ await git.fetch("origin", baseBranch);
521
+ return (await git.revparse([`origin/${baseBranch}`])).trim();
522
+ }
523
+ async function resetOrCreateBranch(branchName, sha) {
524
+ const branches = await git.branch();
525
+ const localExists = Object.keys(branches.branches).some((b) => b === branchName);
526
+ if (localExists) {
527
+ await git.checkout(branchName);
528
+ await git.reset(["--hard", sha]);
529
+ } else {
530
+ await git.checkoutBranch(branchName, sha);
531
+ }
532
+ }
533
+
534
+ // src/tools/pick/index.ts
535
+ init_github();
489
536
 
490
537
  // src/lib/config.ts
491
538
  import Conf from "conf";
@@ -496,6 +543,7 @@ var configSchema = z.object({
496
543
  aiModel: z.string().optional(),
497
544
  githubToken: z.string().min(1),
498
545
  githubClientId: z.string().optional(),
546
+ baseBranch: z.string().optional(),
499
547
  github: z.object({
500
548
  owner: z.string().min(1),
501
549
  repo: z.string().min(1)
@@ -540,6 +588,9 @@ function setConfig(partial) {
540
588
  if (partial.githubClientId !== void 0) {
541
589
  current["githubClientId"] = partial.githubClientId;
542
590
  }
591
+ if (partial.baseBranch !== void 0) {
592
+ current["baseBranch"] = partial.baseBranch;
593
+ }
543
594
  if (partial.taskState !== void 0) {
544
595
  current["taskState"] = {
545
596
  ...current["taskState"] ?? {},
@@ -646,7 +697,8 @@ async function launchClaudeCode(issue, branch) {
646
697
  const prompt = buildClaudePrompt(issue, branch);
647
698
  console.log(chalk3.dim("\n Launching Claude Code\u2026\n"));
648
699
  await new Promise((resolve) => {
649
- const child = spawn("claude", [prompt], { stdio: "inherit", shell: true });
700
+ const safePrompt = prompt.replace(/\r?\n/g, " ").replace(/"/g, "'");
701
+ const child = spawn(`claude "${safePrompt}"`, [], { stdio: "inherit", shell: true });
650
702
  child.on("close", () => resolve());
651
703
  child.on("error", () => {
652
704
  console.log(
@@ -858,8 +910,16 @@ async function run(_input, config) {
858
910
  return `Commit failed: ${err.message}`;
859
911
  }
860
912
  if (isSelfSubmit) {
913
+ spinner = ora("Closing issue\u2026").start();
914
+ try {
915
+ await closeTask(config, issueNumber);
916
+ spinner.stop();
917
+ } catch (err) {
918
+ spinner.stop();
919
+ console.error(chalk5.yellow(`Warning: failed to close issue: ${err.message}`));
920
+ }
861
921
  setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
862
- return `Task #${issueNumber} committed.
922
+ return `Task #${issueNumber} committed and closed.
863
923
  Commit: "${commitMessage.trim()}"`;
864
924
  }
865
925
  spinner = ora("Creating pull request\u2026").start();
@@ -920,8 +980,12 @@ async function execute(input, config) {
920
980
  return `Commit failed: ${err.message}`;
921
981
  }
922
982
  if (isSelfSubmit) {
983
+ try {
984
+ await closeTask(config, issueNumber);
985
+ } catch {
986
+ }
923
987
  setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
924
- return `Task #${issueNumber} committed.
988
+ return `Task #${issueNumber} committed and closed.
925
989
  Commit: "${commitMessage}"`;
926
990
  }
927
991
  let prUrl;
@@ -1129,41 +1193,29 @@ Finish or submit it before claiming a new one.`;
1129
1193
  await claimTask(config, issue.number, me);
1130
1194
  spinner.stop();
1131
1195
  const workerBranch = makeWorkerBranchName(me);
1132
- spinner = ora3(`Switching to ${workerBranch}\u2026`).start();
1196
+ const taskBase = extractBaseCommit(issue.body);
1197
+ spinner = ora3(`Switching to ${workerBranch}${taskBase ? ` from ${taskBase.slice(0, 7)}` : ""}\u2026`).start();
1133
1198
  try {
1134
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1135
- spinner.stop();
1136
- if (isNewWorker) {
1137
- spinner = ora3("Pushing worker branch\u2026").start();
1138
- try {
1139
- await pushBranch(workerBranch);
1140
- spinner.stop();
1141
- } catch {
1142
- spinner.warn("Could not push worker branch");
1143
- }
1199
+ if (taskBase) {
1200
+ await resetOrCreateBranch(workerBranch, taskBase);
1201
+ } else {
1202
+ await switchToBranchOrCreate(workerBranch);
1144
1203
  }
1145
- } catch {
1146
- spinner.warn(`Could not switch to ${workerBranch}`);
1147
- }
1148
- const branch = makeBranchName(issue.number, me);
1149
- spinner = ora3(`Creating task branch ${branch}\u2026`).start();
1150
- try {
1151
- await switchToBranchOrCreate(branch);
1152
1204
  spinner.stop();
1153
- spinner = ora3("Pushing task branch\u2026").start();
1205
+ spinner = ora3("Pushing worker branch\u2026").start();
1154
1206
  try {
1155
- await pushBranch(branch);
1207
+ await pushBranch(workerBranch);
1156
1208
  spinner.stop();
1157
1209
  } catch {
1158
- spinner.warn("Could not push task branch");
1210
+ spinner.warn("Could not push worker branch");
1159
1211
  }
1160
1212
  } catch {
1161
- spinner.warn(`Could not create branch ${branch}`);
1213
+ spinner.warn(`Could not switch to ${workerBranch}`);
1162
1214
  }
1163
1215
  const baseCommit = await getCurrentCommit();
1164
1216
  setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1165
1217
  console.log(chalk6.green(`
1166
- Claimed! Branch: ${branch} (base: ${baseCommit.slice(0, 7)})
1218
+ Claimed! Branch: ${workerBranch} (base: ${baseCommit.slice(0, 7)})
1167
1219
  `));
1168
1220
  let openClaude;
1169
1221
  try {
@@ -1177,8 +1229,8 @@ Finish or submit it before claiming a new one.`;
1177
1229
  } catch {
1178
1230
  openClaude = false;
1179
1231
  }
1180
- if (openClaude) await launchClaudeCode(issue, branch);
1181
- return `Task #${issue.number} claimed. Branch: ${branch}`;
1232
+ if (openClaude) await launchClaudeCode(issue, workerBranch);
1233
+ return `Task #${issue.number} claimed. Branch: ${workerBranch}`;
1182
1234
  } catch (err) {
1183
1235
  return `Error claiming task: ${err.message}`;
1184
1236
  }
@@ -1217,28 +1269,22 @@ async function execute3(input, config) {
1217
1269
  return `Error claiming task: ${err.message}`;
1218
1270
  }
1219
1271
  const workerBranch = makeWorkerBranchName(me);
1272
+ const taskBase = extractBaseCommit(issue.body);
1220
1273
  try {
1221
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1222
- if (isNewWorker) {
1223
- try {
1224
- await pushBranch(workerBranch);
1225
- } catch {
1226
- }
1274
+ if (taskBase) {
1275
+ await resetOrCreateBranch(workerBranch, taskBase);
1276
+ } else {
1277
+ await switchToBranchOrCreate(workerBranch);
1227
1278
  }
1228
1279
  } catch {
1229
1280
  }
1230
- const branch = makeBranchName(issueNumber, me);
1231
- try {
1232
- await switchToBranchOrCreate(branch);
1233
- } catch {
1234
- }
1235
1281
  try {
1236
- await pushBranch(branch);
1282
+ await pushBranch(workerBranch);
1237
1283
  } catch {
1238
1284
  }
1239
1285
  const baseCommit = await getCurrentCommit();
1240
1286
  setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1241
- return `Task #${issueNumber} claimed. Branch: ${branch} (base commit: ${baseCommit.slice(0, 7)})`;
1287
+ return `Task #${issueNumber} claimed. Branch: ${workerBranch} (base commit: ${baseCommit.slice(0, 7)})`;
1242
1288
  }
1243
1289
  return `Unknown action: ${action}`;
1244
1290
  }
@@ -1408,12 +1454,26 @@ async function run4(input, config) {
1408
1454
  console.log(chalk7.yellow(` Revision error: ${err.message}`));
1409
1455
  }
1410
1456
  }
1457
+ const baseBranch = config.baseBranch ?? "main";
1458
+ let baseCommit;
1459
+ const syncSpinner = ora4(`Syncing with ${baseBranch}\u2026`).start();
1460
+ try {
1461
+ await syncWithBase(baseBranch);
1462
+ baseCommit = await getCurrentCommit();
1463
+ syncSpinner.succeed(`Synced with ${baseBranch} (base: ${baseCommit.slice(0, 7)})`);
1464
+ } catch {
1465
+ syncSpinner.warn(`Could not sync with ${baseBranch} \u2014 recording remote HEAD as base`);
1466
+ try {
1467
+ baseCommit = await getRemoteHeadSha(baseBranch);
1468
+ } catch {
1469
+ }
1470
+ }
1411
1471
  const createSpinner = ora4(`Creating "${title}"\u2026`).start();
1412
1472
  let htmlUrl;
1413
1473
  let issueNumber;
1414
1474
  let issueTitle;
1415
1475
  try {
1416
- const issue = await createTask(config, title, guide);
1476
+ const issue = await createTask(config, title, guide, baseCommit);
1417
1477
  createSpinner.stop();
1418
1478
  htmlUrl = issue.htmlUrl;
1419
1479
  issueNumber = issue.number;
@@ -1450,8 +1510,19 @@ async function execute4(input, config) {
1450
1510
  if (feedback) {
1451
1511
  guide = await generateGuide(config, title, { feedback, previousGuide: guide });
1452
1512
  }
1513
+ const baseBranch = config.baseBranch ?? "main";
1514
+ let baseCommit;
1515
+ try {
1516
+ await syncWithBase(baseBranch);
1517
+ baseCommit = await getCurrentCommit();
1518
+ } catch {
1519
+ try {
1520
+ baseCommit = await getRemoteHeadSha(baseBranch);
1521
+ } catch {
1522
+ }
1523
+ }
1453
1524
  try {
1454
- const issue = await createTask(config, title, guide);
1525
+ const issue = await createTask(config, title, guide, baseCommit);
1455
1526
  return `Created #${issue.number} "${issue.title}" \u2014 ${issue.htmlUrl}
1456
1527
 
1457
1528
  Guide:
@@ -1852,16 +1923,40 @@ async function run10(input, config) {
1852
1923
  }
1853
1924
  if (!confirmed) return "Cancelled.";
1854
1925
  const spinner = ora8(`Merging PR for #${issueNumber}\u2026`).start();
1926
+ let result;
1855
1927
  try {
1856
- const result = await acceptTask(config, issueNumber);
1928
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
1929
+ result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
1857
1930
  spinner.succeed(`PR #${result.prNumber} merged into ${targetBranch}`);
1858
- return `Task #${issueNumber} accepted.
1859
- PR #${result.prNumber} merged \u2192 ${targetBranch}
1860
- Issue closed.`;
1861
1931
  } catch (err) {
1862
1932
  spinner.fail("Failed");
1863
1933
  return `Error: ${err.message}`;
1864
1934
  }
1935
+ const baseBranch = config.baseBranch ?? "main";
1936
+ let pushToMain;
1937
+ try {
1938
+ pushToMain = await select6({
1939
+ message: `Push ${chalk9.cyan(targetBranch)} \u2192 ${chalk9.cyan(baseBranch)}?`,
1940
+ choices: [
1941
+ { name: `Yes, push to ${baseBranch}`, value: true },
1942
+ { name: "No, keep in worker branch", value: false }
1943
+ ]
1944
+ });
1945
+ } catch {
1946
+ pushToMain = false;
1947
+ }
1948
+ if (pushToMain) {
1949
+ const mergeSpinner = ora8(`Merging ${targetBranch} \u2192 ${baseBranch}\u2026`).start();
1950
+ try {
1951
+ await mergeWorkerIntoBase(config, targetBranch, baseBranch);
1952
+ mergeSpinner.succeed(`Merged ${targetBranch} \u2192 ${baseBranch}`);
1953
+ } catch (err) {
1954
+ mergeSpinner.fail(`Could not merge to ${baseBranch}: ${err.message}`);
1955
+ }
1956
+ }
1957
+ return `Task #${issueNumber} accepted.
1958
+ PR #${result.prNumber} merged \u2192 ${targetBranch}${pushToMain ? ` \u2192 ${baseBranch}` : ""}
1959
+ Issue closed.`;
1865
1960
  }
1866
1961
  async function execute10(input, config) {
1867
1962
  const issueNumber = input["issue_number"];
@@ -1875,7 +1970,8 @@ async function execute10(input, config) {
1875
1970
  const targetBranch = makeWorkerBranchName(me);
1876
1971
  const spinner = ora8(`Merging PR for #${issueNumber}\u2026`).start();
1877
1972
  try {
1878
- const result = await acceptTask(config, issueNumber);
1973
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
1974
+ const result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
1879
1975
  spinner.stop();
1880
1976
  return `Task #${issueNumber} accepted.
1881
1977
  PR #${result.prNumber} merged \u2192 ${targetBranch}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "techunter",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "AI-powered task distribution CLI for development teams",
5
5
  "author": "Techunter Contributors",
6
6
  "license": "MIT",