techunter 0.1.6 → 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.
Files changed (3) hide show
  1. package/dist/index.js +151 -53
  2. package/dist/mcp.js +140 -56
  3. package/package.json +1 -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,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(
@@ -1425,41 +1489,29 @@ Finish or submit it before claiming a new one.`;
1425
1489
  await claimTask(config, issue.number, me);
1426
1490
  spinner.stop();
1427
1491
  const workerBranch = makeWorkerBranchName(me);
1428
- 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();
1429
1494
  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
- }
1495
+ if (taskBase) {
1496
+ await resetOrCreateBranch(workerBranch, taskBase);
1497
+ } else {
1498
+ await switchToBranchOrCreate(workerBranch);
1440
1499
  }
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
1500
  spinner.stop();
1449
- spinner = ora4("Pushing task branch\u2026").start();
1501
+ spinner = ora4("Pushing worker branch\u2026").start();
1450
1502
  try {
1451
- await pushBranch(branch);
1503
+ await pushBranch(workerBranch);
1452
1504
  spinner.stop();
1453
1505
  } catch {
1454
- spinner.warn("Could not push task branch");
1506
+ spinner.warn("Could not push worker branch");
1455
1507
  }
1456
1508
  } catch {
1457
- spinner.warn(`Could not create branch ${branch}`);
1509
+ spinner.warn(`Could not switch to ${workerBranch}`);
1458
1510
  }
1459
1511
  const baseCommit = await getCurrentCommit();
1460
1512
  setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1461
1513
  console.log(chalk8.green(`
1462
- Claimed! Branch: ${branch} (base: ${baseCommit.slice(0, 7)})
1514
+ Claimed! Branch: ${workerBranch} (base: ${baseCommit.slice(0, 7)})
1463
1515
  `));
1464
1516
  let openClaude;
1465
1517
  try {
@@ -1473,8 +1525,8 @@ Finish or submit it before claiming a new one.`;
1473
1525
  } catch {
1474
1526
  openClaude = false;
1475
1527
  }
1476
- if (openClaude) await launchClaudeCode(issue, branch);
1477
- return `Task #${issue.number} claimed. Branch: ${branch}`;
1528
+ if (openClaude) await launchClaudeCode(issue, workerBranch);
1529
+ return `Task #${issue.number} claimed. Branch: ${workerBranch}`;
1478
1530
  } catch (err) {
1479
1531
  return `Error claiming task: ${err.message}`;
1480
1532
  }
@@ -1513,28 +1565,22 @@ async function execute3(input3, config) {
1513
1565
  return `Error claiming task: ${err.message}`;
1514
1566
  }
1515
1567
  const workerBranch = makeWorkerBranchName(me);
1568
+ const taskBase = extractBaseCommit(issue.body);
1516
1569
  try {
1517
- const isNewWorker = await switchToBranchOrCreate(workerBranch);
1518
- if (isNewWorker) {
1519
- try {
1520
- await pushBranch(workerBranch);
1521
- } catch {
1522
- }
1570
+ if (taskBase) {
1571
+ await resetOrCreateBranch(workerBranch, taskBase);
1572
+ } else {
1573
+ await switchToBranchOrCreate(workerBranch);
1523
1574
  }
1524
1575
  } catch {
1525
1576
  }
1526
- const branch = makeBranchName(issueNumber, me);
1527
1577
  try {
1528
- await switchToBranchOrCreate(branch);
1529
- } catch {
1530
- }
1531
- try {
1532
- await pushBranch(branch);
1578
+ await pushBranch(workerBranch);
1533
1579
  } catch {
1534
1580
  }
1535
1581
  const baseCommit = await getCurrentCommit();
1536
1582
  setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1537
- 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)})`;
1538
1584
  }
1539
1585
  return `Unknown action: ${action}`;
1540
1586
  }
@@ -1704,12 +1750,26 @@ async function run4(input3, config) {
1704
1750
  console.log(chalk9.yellow(` Revision error: ${err.message}`));
1705
1751
  }
1706
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
+ }
1707
1767
  const createSpinner = ora5(`Creating "${title}"\u2026`).start();
1708
1768
  let htmlUrl;
1709
1769
  let issueNumber;
1710
1770
  let issueTitle;
1711
1771
  try {
1712
- const issue = await createTask(config, title, guide);
1772
+ const issue = await createTask(config, title, guide, baseCommit);
1713
1773
  createSpinner.stop();
1714
1774
  htmlUrl = issue.htmlUrl;
1715
1775
  issueNumber = issue.number;
@@ -1746,8 +1806,19 @@ async function execute4(input3, config) {
1746
1806
  if (feedback) {
1747
1807
  guide = await generateGuide(config, title, { feedback, previousGuide: guide });
1748
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
+ }
1749
1820
  try {
1750
- const issue = await createTask(config, title, guide);
1821
+ const issue = await createTask(config, title, guide, baseCommit);
1751
1822
  return `Created #${issue.number} "${issue.title}" \u2014 ${issue.htmlUrl}
1752
1823
 
1753
1824
  Guide:
@@ -2148,16 +2219,40 @@ async function run10(input3, config) {
2148
2219
  }
2149
2220
  if (!confirmed) return "Cancelled.";
2150
2221
  const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2222
+ let result;
2151
2223
  try {
2152
- const result = await acceptTask(config, issueNumber);
2224
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2225
+ result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2153
2226
  spinner.succeed(`PR #${result.prNumber} merged into ${targetBranch}`);
2154
- return `Task #${issueNumber} accepted.
2155
- PR #${result.prNumber} merged \u2192 ${targetBranch}
2156
- Issue closed.`;
2157
2227
  } catch (err) {
2158
2228
  spinner.fail("Failed");
2159
2229
  return `Error: ${err.message}`;
2160
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.`;
2161
2256
  }
2162
2257
  async function execute10(input3, config) {
2163
2258
  const issueNumber = input3["issue_number"];
@@ -2171,7 +2266,8 @@ async function execute10(input3, config) {
2171
2266
  const targetBranch = makeWorkerBranchName(me);
2172
2267
  const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2173
2268
  try {
2174
- const result = await acceptTask(config, issueNumber);
2269
+ const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2270
+ const result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2175
2271
  spinner.stop();
2176
2272
  return `Task #${issueNumber} accepted.
2177
2273
  PR #${result.prNumber} merged \u2192 ${targetBranch}
@@ -2933,6 +3029,8 @@ function completer(line) {
2933
3029
  var _rl = null;
2934
3030
  function promptUser() {
2935
3031
  return new Promise((resolve) => {
3032
+ if (process.stdin.isPaused()) process.stdin.resume();
3033
+ _rl.resume();
2936
3034
  _rl.question(chalk14.cyan("You") + chalk14.dim(" \u203A "), resolve);
2937
3035
  });
2938
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(
@@ -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.7",
4
4
  "description": "AI-powered task distribution CLI for development teams",
5
5
  "author": "Techunter Contributors",
6
6
  "license": "MIT",