techunter 0.1.6 → 0.1.8

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 (3) hide show
  1. package/dist/index.js +161 -53
  2. package/dist/mcp.js +140 -56
  3. package/package.json +2 -1
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,9 +363,19 @@ 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
+ // src/lib/proxy.ts
371
+ import { ProxyAgent, setGlobalDispatcher } from "undici";
372
+ function setupProxy() {
373
+ const proxy = process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? process.env.ALL_PROXY ?? process.env.all_proxy;
374
+ if (proxy) {
375
+ setGlobalDispatcher(new ProxyAgent(proxy));
376
+ }
377
+ }
378
+
344
379
  // src/index.ts
345
380
  import chalk14 from "chalk";
346
381
  import readline from "readline";
@@ -362,6 +397,7 @@ var configSchema = z.object({
362
397
  aiModel: z.string().optional(),
363
398
  githubToken: z.string().min(1),
364
399
  githubClientId: z.string().optional(),
400
+ baseBranch: z.string().optional(),
365
401
  github: z.object({
366
402
  owner: z.string().min(1),
367
403
  repo: z.string().min(1)
@@ -406,6 +442,9 @@ function setConfig(partial) {
406
442
  if (partial.githubClientId !== void 0) {
407
443
  current["githubClientId"] = partial.githubClientId;
408
444
  }
445
+ if (partial.baseBranch !== void 0) {
446
+ current["baseBranch"] = partial.baseBranch;
447
+ }
409
448
  if (partial.taskState !== void 0) {
410
449
  current["taskState"] = {
411
450
  ...current["taskState"] ?? {},
@@ -568,6 +607,28 @@ async function stageAllAndCommit(message) {
568
607
  const branch = (await git.branch()).current;
569
608
  await git.push("origin", branch, ["--set-upstream"]);
570
609
  }
610
+ async function syncWithBase(baseBranch) {
611
+ await git.fetch("origin", baseBranch);
612
+ try {
613
+ await git.merge([`origin/${baseBranch}`, "--ff-only"]);
614
+ } catch {
615
+ await git.merge([`origin/${baseBranch}`, "-m", `chore: sync with ${baseBranch}`]);
616
+ }
617
+ }
618
+ async function getRemoteHeadSha(baseBranch) {
619
+ await git.fetch("origin", baseBranch);
620
+ return (await git.revparse([`origin/${baseBranch}`])).trim();
621
+ }
622
+ async function resetOrCreateBranch(branchName, sha) {
623
+ const branches = await git.branch();
624
+ const localExists = Object.keys(branches.branches).some((b) => b === branchName);
625
+ if (localExists) {
626
+ await git.checkout(branchName);
627
+ await git.reset(["--hard", sha]);
628
+ } else {
629
+ await git.checkoutBranch(branchName, sha);
630
+ }
631
+ }
571
632
 
572
633
  // src/lib/client.ts
573
634
  import OpenAI from "openai";
@@ -736,10 +797,12 @@ async function configCommand() {
736
797
  `));
737
798
  const currentBaseUrl = config.aiBaseUrl ?? DEFAULT_BASE_URL;
738
799
  const currentModel = config.aiModel ?? DEFAULT_MODEL;
800
+ const currentBaseBranch = config.baseBranch ?? "main";
739
801
  const field = await select2({
740
802
  message: "Which setting to change?",
741
803
  choices: [
742
804
  { name: `GitHub repo ${chalk3.dim(`${config.github.owner}/${config.github.repo}`)}`, value: "repo" },
805
+ { name: `Base branch ${chalk3.dim(currentBaseBranch)}`, value: "baseBranch" },
743
806
  { name: `AI base URL ${chalk3.dim(currentBaseUrl)}`, value: "aiBaseUrl" },
744
807
  { name: `AI model ${chalk3.dim(currentModel)}`, value: "aiModel" },
745
808
  { name: `AI API Key ${chalk3.dim("(hidden)")}`, value: "aiApiKey" },
@@ -748,7 +811,15 @@ async function configCommand() {
748
811
  ]
749
812
  });
750
813
  if (field === "cancel") return;
751
- if (field === "repo") {
814
+ if (field === "baseBranch") {
815
+ const val = await input2({ message: "Base branch name:", default: currentBaseBranch });
816
+ if (val.trim()) {
817
+ setConfig({ baseBranch: val.trim() });
818
+ console.log(chalk3.green(`
819
+ Base branch set to: ${val.trim()}
820
+ `));
821
+ }
822
+ } else if (field === "repo") {
752
823
  const owner = await input2({ message: "GitHub repo owner:", default: config.github.owner });
753
824
  const repo = await input2({ message: "GitHub repo name:", default: config.github.repo });
754
825
  setConfig({ github: { ...config.github, owner: owner.trim(), repo: repo.trim() } });
@@ -805,6 +876,7 @@ init_github();
805
876
  import chalk8 from "chalk";
806
877
  import ora4 from "ora";
807
878
  import { select as select5 } from "@inquirer/prompts";
879
+ init_github();
808
880
 
809
881
  // src/lib/markdown.ts
810
882
  import { marked } from "marked";
@@ -944,7 +1016,8 @@ async function launchClaudeCode(issue, branch) {
944
1016
  const prompt = buildClaudePrompt(issue, branch);
945
1017
  console.log(chalk5.dim("\n Launching Claude Code\u2026\n"));
946
1018
  await new Promise((resolve) => {
947
- const child = spawn("claude", [prompt], { stdio: "inherit", shell: true });
1019
+ const safePrompt = prompt.replace(/\r?\n/g, " ").replace(/"/g, "'");
1020
+ const child = spawn(`claude "${safePrompt}"`, [], { stdio: "inherit", shell: true });
948
1021
  child.on("close", () => resolve());
949
1022
  child.on("error", () => {
950
1023
  console.log(
@@ -1425,41 +1498,29 @@ Finish or submit it before claiming a new one.`;
1425
1498
  await claimTask(config, issue.number, me);
1426
1499
  spinner.stop();
1427
1500
  const workerBranch = makeWorkerBranchName(me);
1428
- spinner = ora4(`Switching to ${workerBranch}\u2026`).start();
1501
+ const taskBase = extractBaseCommit(issue.body);
1502
+ spinner = ora4(`Switching to ${workerBranch}${taskBase ? ` from ${taskBase.slice(0, 7)}` : ""}\u2026`).start();
1429
1503
  try {
1430
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1431
- spinner.stop();
1432
- if (isNewWorker) {
1433
- spinner = ora4("Pushing worker branch\u2026").start();
1434
- try {
1435
- await pushBranch(workerBranch);
1436
- spinner.stop();
1437
- } catch {
1438
- spinner.warn("Could not push worker branch");
1439
- }
1504
+ if (taskBase) {
1505
+ await resetOrCreateBranch(workerBranch, taskBase);
1506
+ } else {
1507
+ await switchToBranchOrCreate(workerBranch);
1440
1508
  }
1441
- } catch {
1442
- spinner.warn(`Could not switch to ${workerBranch}`);
1443
- }
1444
- const branch = makeBranchName(issue.number, me);
1445
- spinner = ora4(`Creating task branch ${branch}\u2026`).start();
1446
- try {
1447
- await switchToBranchOrCreate(branch);
1448
1509
  spinner.stop();
1449
- spinner = ora4("Pushing task branch\u2026").start();
1510
+ spinner = ora4("Pushing worker branch\u2026").start();
1450
1511
  try {
1451
- await pushBranch(branch);
1512
+ await pushBranch(workerBranch);
1452
1513
  spinner.stop();
1453
1514
  } catch {
1454
- spinner.warn("Could not push task branch");
1515
+ spinner.warn("Could not push worker branch");
1455
1516
  }
1456
1517
  } catch {
1457
- spinner.warn(`Could not create branch ${branch}`);
1518
+ spinner.warn(`Could not switch to ${workerBranch}`);
1458
1519
  }
1459
1520
  const baseCommit = await getCurrentCommit();
1460
1521
  setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1461
1522
  console.log(chalk8.green(`
1462
- Claimed! Branch: ${branch} (base: ${baseCommit.slice(0, 7)})
1523
+ Claimed! Branch: ${workerBranch} (base: ${baseCommit.slice(0, 7)})
1463
1524
  `));
1464
1525
  let openClaude;
1465
1526
  try {
@@ -1473,8 +1534,8 @@ Finish or submit it before claiming a new one.`;
1473
1534
  } catch {
1474
1535
  openClaude = false;
1475
1536
  }
1476
- if (openClaude) await launchClaudeCode(issue, branch);
1477
- return `Task #${issue.number} claimed. Branch: ${branch}`;
1537
+ if (openClaude) await launchClaudeCode(issue, workerBranch);
1538
+ return `Task #${issue.number} claimed. Branch: ${workerBranch}`;
1478
1539
  } catch (err) {
1479
1540
  return `Error claiming task: ${err.message}`;
1480
1541
  }
@@ -1513,28 +1574,22 @@ async function execute3(input3, config) {
1513
1574
  return `Error claiming task: ${err.message}`;
1514
1575
  }
1515
1576
  const workerBranch = makeWorkerBranchName(me);
1577
+ const taskBase = extractBaseCommit(issue.body);
1516
1578
  try {
1517
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1518
- if (isNewWorker) {
1519
- try {
1520
- await pushBranch(workerBranch);
1521
- } catch {
1522
- }
1579
+ if (taskBase) {
1580
+ await resetOrCreateBranch(workerBranch, taskBase);
1581
+ } else {
1582
+ await switchToBranchOrCreate(workerBranch);
1523
1583
  }
1524
1584
  } catch {
1525
1585
  }
1526
- const branch = makeBranchName(issueNumber, me);
1527
- try {
1528
- await switchToBranchOrCreate(branch);
1529
- } catch {
1530
- }
1531
1586
  try {
1532
- await pushBranch(branch);
1587
+ await pushBranch(workerBranch);
1533
1588
  } catch {
1534
1589
  }
1535
1590
  const baseCommit = await getCurrentCommit();
1536
1591
  setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1537
- return `Task #${issueNumber} claimed. Branch: ${branch} (base commit: ${baseCommit.slice(0, 7)})`;
1592
+ return `Task #${issueNumber} claimed. Branch: ${workerBranch} (base commit: ${baseCommit.slice(0, 7)})`;
1538
1593
  }
1539
1594
  return `Unknown action: ${action}`;
1540
1595
  }
@@ -1704,12 +1759,26 @@ async function run4(input3, config) {
1704
1759
  console.log(chalk9.yellow(` Revision error: ${err.message}`));
1705
1760
  }
1706
1761
  }
1762
+ const baseBranch = config.baseBranch ?? "main";
1763
+ let baseCommit;
1764
+ const syncSpinner = ora5(`Syncing with ${baseBranch}\u2026`).start();
1765
+ try {
1766
+ await syncWithBase(baseBranch);
1767
+ baseCommit = await getCurrentCommit();
1768
+ syncSpinner.succeed(`Synced with ${baseBranch} (base: ${baseCommit.slice(0, 7)})`);
1769
+ } catch {
1770
+ syncSpinner.warn(`Could not sync with ${baseBranch} \u2014 recording remote HEAD as base`);
1771
+ try {
1772
+ baseCommit = await getRemoteHeadSha(baseBranch);
1773
+ } catch {
1774
+ }
1775
+ }
1707
1776
  const createSpinner = ora5(`Creating "${title}"\u2026`).start();
1708
1777
  let htmlUrl;
1709
1778
  let issueNumber;
1710
1779
  let issueTitle;
1711
1780
  try {
1712
- const issue = await createTask(config, title, guide);
1781
+ const issue = await createTask(config, title, guide, baseCommit);
1713
1782
  createSpinner.stop();
1714
1783
  htmlUrl = issue.htmlUrl;
1715
1784
  issueNumber = issue.number;
@@ -1746,8 +1815,19 @@ async function execute4(input3, config) {
1746
1815
  if (feedback) {
1747
1816
  guide = await generateGuide(config, title, { feedback, previousGuide: guide });
1748
1817
  }
1818
+ const baseBranch = config.baseBranch ?? "main";
1819
+ let baseCommit;
1749
1820
  try {
1750
- const issue = await createTask(config, title, guide);
1821
+ await syncWithBase(baseBranch);
1822
+ baseCommit = await getCurrentCommit();
1823
+ } catch {
1824
+ try {
1825
+ baseCommit = await getRemoteHeadSha(baseBranch);
1826
+ } catch {
1827
+ }
1828
+ }
1829
+ try {
1830
+ const issue = await createTask(config, title, guide, baseCommit);
1751
1831
  return `Created #${issue.number} "${issue.title}" \u2014 ${issue.htmlUrl}
1752
1832
 
1753
1833
  Guide:
@@ -2148,16 +2228,40 @@ async function run10(input3, config) {
2148
2228
  }
2149
2229
  if (!confirmed) return "Cancelled.";
2150
2230
  const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2231
+ let result;
2151
2232
  try {
2152
- const result = await acceptTask(config, issueNumber);
2233
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2234
+ result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2153
2235
  spinner.succeed(`PR #${result.prNumber} merged into ${targetBranch}`);
2154
- return `Task #${issueNumber} accepted.
2155
- PR #${result.prNumber} merged \u2192 ${targetBranch}
2156
- Issue closed.`;
2157
2236
  } catch (err) {
2158
2237
  spinner.fail("Failed");
2159
2238
  return `Error: ${err.message}`;
2160
2239
  }
2240
+ const baseBranch = config.baseBranch ?? "main";
2241
+ let pushToMain;
2242
+ try {
2243
+ pushToMain = await select8({
2244
+ message: `Push ${chalk11.cyan(targetBranch)} \u2192 ${chalk11.cyan(baseBranch)}?`,
2245
+ choices: [
2246
+ { name: `Yes, push to ${baseBranch}`, value: true },
2247
+ { name: "No, keep in worker branch", value: false }
2248
+ ]
2249
+ });
2250
+ } catch {
2251
+ pushToMain = false;
2252
+ }
2253
+ if (pushToMain) {
2254
+ const mergeSpinner = ora9(`Merging ${targetBranch} \u2192 ${baseBranch}\u2026`).start();
2255
+ try {
2256
+ await mergeWorkerIntoBase(config, targetBranch, baseBranch);
2257
+ mergeSpinner.succeed(`Merged ${targetBranch} \u2192 ${baseBranch}`);
2258
+ } catch (err) {
2259
+ mergeSpinner.fail(`Could not merge to ${baseBranch}: ${err.message}`);
2260
+ }
2261
+ }
2262
+ return `Task #${issueNumber} accepted.
2263
+ PR #${result.prNumber} merged \u2192 ${targetBranch}${pushToMain ? ` \u2192 ${baseBranch}` : ""}
2264
+ Issue closed.`;
2161
2265
  }
2162
2266
  async function execute10(input3, config) {
2163
2267
  const issueNumber = input3["issue_number"];
@@ -2171,7 +2275,8 @@ async function execute10(input3, config) {
2171
2275
  const targetBranch = makeWorkerBranchName(me);
2172
2276
  const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2173
2277
  try {
2174
- const result = await acceptTask(config, issueNumber);
2278
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2279
+ const result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2175
2280
  spinner.stop();
2176
2281
  return `Task #${issueNumber} accepted.
2177
2282
  PR #${result.prNumber} merged \u2192 ${targetBranch}
@@ -2894,6 +2999,7 @@ async function runAgentLoop(config, messages) {
2894
2999
  }
2895
3000
 
2896
3001
  // src/index.ts
3002
+ setupProxy();
2897
3003
  var _require = createRequire(import.meta.url);
2898
3004
  var { version } = _require("../package.json");
2899
3005
  var SLASH_NAMES = [
@@ -2933,6 +3039,8 @@ function completer(line) {
2933
3039
  var _rl = null;
2934
3040
  function promptUser() {
2935
3041
  return new Promise((resolve) => {
3042
+ if (process.stdin.isPaused()) process.stdin.resume();
3043
+ _rl.resume();
2936
3044
  _rl.question(chalk14.cyan("You") + chalk14.dim(" \u203A "), resolve);
2937
3045
  });
2938
3046
  }
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(
@@ -1141,41 +1193,29 @@ Finish or submit it before claiming a new one.`;
1141
1193
  await claimTask(config, issue.number, me);
1142
1194
  spinner.stop();
1143
1195
  const workerBranch = makeWorkerBranchName(me);
1144
- 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();
1145
1198
  try {
1146
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1147
- spinner.stop();
1148
- if (isNewWorker) {
1149
- spinner = ora3("Pushing worker branch\u2026").start();
1150
- try {
1151
- await pushBranch(workerBranch);
1152
- spinner.stop();
1153
- } catch {
1154
- spinner.warn("Could not push worker branch");
1155
- }
1199
+ if (taskBase) {
1200
+ await resetOrCreateBranch(workerBranch, taskBase);
1201
+ } else {
1202
+ await switchToBranchOrCreate(workerBranch);
1156
1203
  }
1157
- } catch {
1158
- spinner.warn(`Could not switch to ${workerBranch}`);
1159
- }
1160
- const branch = makeBranchName(issue.number, me);
1161
- spinner = ora3(`Creating task branch ${branch}\u2026`).start();
1162
- try {
1163
- await switchToBranchOrCreate(branch);
1164
1204
  spinner.stop();
1165
- spinner = ora3("Pushing task branch\u2026").start();
1205
+ spinner = ora3("Pushing worker branch\u2026").start();
1166
1206
  try {
1167
- await pushBranch(branch);
1207
+ await pushBranch(workerBranch);
1168
1208
  spinner.stop();
1169
1209
  } catch {
1170
- spinner.warn("Could not push task branch");
1210
+ spinner.warn("Could not push worker branch");
1171
1211
  }
1172
1212
  } catch {
1173
- spinner.warn(`Could not create branch ${branch}`);
1213
+ spinner.warn(`Could not switch to ${workerBranch}`);
1174
1214
  }
1175
1215
  const baseCommit = await getCurrentCommit();
1176
1216
  setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1177
1217
  console.log(chalk6.green(`
1178
- Claimed! Branch: ${branch} (base: ${baseCommit.slice(0, 7)})
1218
+ Claimed! Branch: ${workerBranch} (base: ${baseCommit.slice(0, 7)})
1179
1219
  `));
1180
1220
  let openClaude;
1181
1221
  try {
@@ -1189,8 +1229,8 @@ Finish or submit it before claiming a new one.`;
1189
1229
  } catch {
1190
1230
  openClaude = false;
1191
1231
  }
1192
- if (openClaude) await launchClaudeCode(issue, branch);
1193
- return `Task #${issue.number} claimed. Branch: ${branch}`;
1232
+ if (openClaude) await launchClaudeCode(issue, workerBranch);
1233
+ return `Task #${issue.number} claimed. Branch: ${workerBranch}`;
1194
1234
  } catch (err) {
1195
1235
  return `Error claiming task: ${err.message}`;
1196
1236
  }
@@ -1229,28 +1269,22 @@ async function execute3(input, config) {
1229
1269
  return `Error claiming task: ${err.message}`;
1230
1270
  }
1231
1271
  const workerBranch = makeWorkerBranchName(me);
1272
+ const taskBase = extractBaseCommit(issue.body);
1232
1273
  try {
1233
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1234
- if (isNewWorker) {
1235
- try {
1236
- await pushBranch(workerBranch);
1237
- } catch {
1238
- }
1274
+ if (taskBase) {
1275
+ await resetOrCreateBranch(workerBranch, taskBase);
1276
+ } else {
1277
+ await switchToBranchOrCreate(workerBranch);
1239
1278
  }
1240
1279
  } catch {
1241
1280
  }
1242
- const branch = makeBranchName(issueNumber, me);
1243
- try {
1244
- await switchToBranchOrCreate(branch);
1245
- } catch {
1246
- }
1247
1281
  try {
1248
- await pushBranch(branch);
1282
+ await pushBranch(workerBranch);
1249
1283
  } catch {
1250
1284
  }
1251
1285
  const baseCommit = await getCurrentCommit();
1252
1286
  setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1253
- 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)})`;
1254
1288
  }
1255
1289
  return `Unknown action: ${action}`;
1256
1290
  }
@@ -1420,12 +1454,26 @@ async function run4(input, config) {
1420
1454
  console.log(chalk7.yellow(` Revision error: ${err.message}`));
1421
1455
  }
1422
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
+ }
1423
1471
  const createSpinner = ora4(`Creating "${title}"\u2026`).start();
1424
1472
  let htmlUrl;
1425
1473
  let issueNumber;
1426
1474
  let issueTitle;
1427
1475
  try {
1428
- const issue = await createTask(config, title, guide);
1476
+ const issue = await createTask(config, title, guide, baseCommit);
1429
1477
  createSpinner.stop();
1430
1478
  htmlUrl = issue.htmlUrl;
1431
1479
  issueNumber = issue.number;
@@ -1462,8 +1510,19 @@ async function execute4(input, config) {
1462
1510
  if (feedback) {
1463
1511
  guide = await generateGuide(config, title, { feedback, previousGuide: guide });
1464
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
+ }
1465
1524
  try {
1466
- const issue = await createTask(config, title, guide);
1525
+ const issue = await createTask(config, title, guide, baseCommit);
1467
1526
  return `Created #${issue.number} "${issue.title}" \u2014 ${issue.htmlUrl}
1468
1527
 
1469
1528
  Guide:
@@ -1864,16 +1923,40 @@ async function run10(input, config) {
1864
1923
  }
1865
1924
  if (!confirmed) return "Cancelled.";
1866
1925
  const spinner = ora8(`Merging PR for #${issueNumber}\u2026`).start();
1926
+ let result;
1867
1927
  try {
1868
- const result = await acceptTask(config, issueNumber);
1928
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
1929
+ result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
1869
1930
  spinner.succeed(`PR #${result.prNumber} merged into ${targetBranch}`);
1870
- return `Task #${issueNumber} accepted.
1871
- PR #${result.prNumber} merged \u2192 ${targetBranch}
1872
- Issue closed.`;
1873
1931
  } catch (err) {
1874
1932
  spinner.fail("Failed");
1875
1933
  return `Error: ${err.message}`;
1876
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.`;
1877
1960
  }
1878
1961
  async function execute10(input, config) {
1879
1962
  const issueNumber = input["issue_number"];
@@ -1887,7 +1970,8 @@ async function execute10(input, config) {
1887
1970
  const targetBranch = makeWorkerBranchName(me);
1888
1971
  const spinner = ora8(`Merging PR for #${issueNumber}\u2026`).start();
1889
1972
  try {
1890
- const result = await acceptTask(config, issueNumber);
1973
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
1974
+ const result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
1891
1975
  spinner.stop();
1892
1976
  return `Task #${issueNumber} accepted.
1893
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.6",
3
+ "version": "0.1.8",
4
4
  "description": "AI-powered task distribution CLI for development teams",
5
5
  "author": "Techunter Contributors",
6
6
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "openai": "^4.104.0",
45
45
  "ora": "^8.1.1",
46
46
  "simple-git": "^3.27.0",
47
+ "undici": "^7.23.0",
47
48
  "zod": "^3.24.1"
48
49
  },
49
50
  "devDependencies": {