techunter 0.1.1 → 0.1.2

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 +223 -69
  2. package/dist/mcp.js +273 -98
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ __export(github_exports, {
24
24
  getBaseBranch: () => getBaseBranch,
25
25
  getDefaultBranch: () => getDefaultBranch,
26
26
  getTask: () => getTask,
27
+ isCollaborator: () => isCollaborator,
27
28
  listComments: () => listComments,
28
29
  listMyTasks: () => listMyTasks,
29
30
  listTasks: () => listTasks,
@@ -50,6 +51,7 @@ function parseIssue(issue) {
50
51
  title: issue.title,
51
52
  body: issue.body ?? null,
52
53
  state: issue.state,
54
+ author: issue.user?.login ?? null,
53
55
  assignee: issue.assignee?.login ?? null,
54
56
  labels: (issue.labels ?? []).map(
55
57
  (l) => typeof l === "string" ? l : l.name ?? ""
@@ -226,6 +228,16 @@ async function getAuthenticatedUser(config) {
226
228
  const { data } = await octokit.users.getAuthenticated();
227
229
  return data.login;
228
230
  }
231
+ async function isCollaborator(config, username) {
232
+ const octokit = createOctokit(config.githubToken);
233
+ const { owner, repo } = config.github;
234
+ try {
235
+ const { data } = await octokit.repos.getCollaboratorPermissionLevel({ owner, repo, username });
236
+ return data.permission === "admin" || data.permission === "write" || data.permission === "maintain";
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
229
241
  async function listMyTasks(config, username) {
230
242
  const octokit = createOctokit(config.githubToken);
231
243
  const { owner, repo } = config.github;
@@ -360,7 +372,11 @@ var configSchema = z.object({
360
372
  owner: z.string().min(1),
361
373
  repo: z.string().min(1),
362
374
  baseBranch: z.string().optional()
363
- })
375
+ }),
376
+ taskState: z.object({
377
+ activeIssueNumber: z.number().optional(),
378
+ baseCommit: z.string().optional()
379
+ }).optional()
364
380
  });
365
381
  var store = new Conf({
366
382
  projectName: "techunter",
@@ -397,6 +413,12 @@ function setConfig(partial) {
397
413
  if (partial.githubClientId !== void 0) {
398
414
  current["githubClientId"] = partial.githubClientId;
399
415
  }
416
+ if (partial.taskState !== void 0) {
417
+ current["taskState"] = {
418
+ ...current["taskState"] ?? {},
419
+ ...partial.taskState
420
+ };
421
+ }
400
422
  store.store = current;
401
423
  }
402
424
  function getConfigPath() {
@@ -414,9 +436,6 @@ async function getCurrentBranch() {
414
436
  const summary = await git.branch();
415
437
  return summary.current;
416
438
  }
417
- async function createAndSwitchBranch(name) {
418
- await git.checkoutLocalBranch(name);
419
- }
420
439
  async function pushBranch(name) {
421
440
  await git.push("origin", name, ["--set-upstream"]);
422
441
  }
@@ -444,6 +463,61 @@ function makeBranchName(issueNumber, username) {
444
463
  const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
445
464
  return `task-${issueNumber}-${slug}`;
446
465
  }
466
+ function makeWorkerBranchName(username) {
467
+ const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
468
+ return `worker-${slug}`;
469
+ }
470
+ async function getCurrentCommit() {
471
+ return (await git.revparse(["HEAD"])).trim();
472
+ }
473
+ async function switchToBranchOrCreate(name) {
474
+ try {
475
+ const branches = await git.branch(["-a"]);
476
+ const exists = Object.keys(branches.branches).some(
477
+ (b) => b === name || b === `remotes/origin/${name}`
478
+ );
479
+ if (exists) {
480
+ await git.checkout(name);
481
+ return false;
482
+ }
483
+ await git.checkoutLocalBranch(name);
484
+ return true;
485
+ } catch {
486
+ await git.checkoutLocalBranch(name);
487
+ return true;
488
+ }
489
+ }
490
+ async function getDiffFromCommit(baseCommit) {
491
+ const status = await git.status();
492
+ const parts = [];
493
+ const fileLines = [
494
+ ...status.modified.map((f) => ` M ${f}`),
495
+ ...status.created.map((f) => ` A ${f}`),
496
+ ...status.deleted.map((f) => ` D ${f}`),
497
+ ...status.renamed.map((f) => ` R ${f.from} \u2192 ${f.to}`),
498
+ ...status.not_added.map((f) => ` ? ${f}`)
499
+ ];
500
+ if (fileLines.length > 0) {
501
+ parts.push("## Uncommitted changes\n" + fileLines.join("\n"));
502
+ const uncommitted = await git.diff(["HEAD"]);
503
+ if (uncommitted) {
504
+ const capped = uncommitted.length > 8e3 ? uncommitted.slice(0, 8e3) + "\n... (truncated)" : uncommitted;
505
+ parts.push("## Uncommitted diff\n```diff\n" + capped + "\n```");
506
+ }
507
+ }
508
+ const log = await git.log({ from: baseCommit, to: "HEAD" });
509
+ if (log.total > 0) {
510
+ const logLines = log.all.map((c) => ` ${c.hash.slice(0, 7)} ${c.message}`);
511
+ parts.push(`## Commits since task claimed (${log.total} total)
512
+ ` + logLines.join("\n"));
513
+ const branchDiff = await git.diff([baseCommit, "HEAD"]);
514
+ if (branchDiff) {
515
+ const capped = branchDiff.length > 12e3 ? branchDiff.slice(0, 12e3) + "\n... (truncated)" : branchDiff;
516
+ parts.push("## Full diff since task claimed\n```diff\n" + capped + "\n```");
517
+ }
518
+ }
519
+ return parts.length > 0 ? parts.join("\n\n") : "No changes since task was claimed.";
520
+ }
447
521
  async function findMergeBase(configuredBase) {
448
522
  const candidates = configuredBase ? [`origin/${configuredBase}`, "origin/main", "origin/master"] : ["origin/main", "origin/master"];
449
523
  const unique = [...new Set(candidates)];
@@ -989,7 +1063,7 @@ async function runSubAgentLoop(config, systemPrompt, userMessage, toolNames) {
989
1063
  }
990
1064
 
991
1065
  // src/tools/submit/prompts.ts
992
- var REVIEWER_SYSTEM_PROMPT = "You are a concise code reviewer. Use run_command to run tests/lint if needed, and read_file to inspect specific files. Then output your review: for each acceptance criterion mark \u2705 met or \u274C not met with a one-line reason. End with an overall verdict line: Ready to submit / Not ready. Reply in the same language as the task.";
1066
+ var REVIEWER_SYSTEM_PROMPT = "You are a concise code reviewer. The diff provided shows all changes on this worker branch since the task was claimed. The branch is shared across tasks, so the diff may contain changes unrelated to this specific task \u2014 use the task title and acceptance criteria to identify which changes are relevant, and ignore the rest. Use run_command to run tests/lint if needed, and read_file to inspect specific files. Then output your review: for each acceptance criterion mark \u2705 met or \u274C not met with a one-line reason. End with an overall verdict line: Ready to submit / Not ready. Reply in the same language as the task.";
993
1067
 
994
1068
  // src/tools/submit/reviewer.ts
995
1069
  async function reviewChanges(config, issueNumber, issue, diff) {
@@ -1023,32 +1097,41 @@ var definition = {
1023
1097
  }
1024
1098
  };
1025
1099
  async function run(_input, config) {
1026
- const branch = await getCurrentBranch();
1027
- const match = branch.match(/^task-(\d+)-/);
1028
- if (!match) {
1029
- return `Not on a task branch (current: ${branch}). Expected format: task-N-title.`;
1100
+ const taskState = getConfig().taskState;
1101
+ const issueNumber = taskState?.activeIssueNumber;
1102
+ if (!issueNumber) {
1103
+ return "No active task found. Claim a task first with /pick.";
1030
1104
  }
1031
- const issueNumber = parseInt(match[1], 10);
1032
1105
  let spinner = ora2("Loading task and diff\u2026").start();
1033
- const [issue, defaultBranch, diff] = await Promise.all([
1106
+ const diffPromise = taskState?.baseCommit ? getDiffFromCommit(taskState.baseCommit) : getDiff(config.github.baseBranch);
1107
+ const [issue, defaultBranch, diff, me] = await Promise.all([
1034
1108
  getTask(config, issueNumber),
1035
1109
  getBaseBranch(config),
1036
- getDiff(config.github.baseBranch)
1110
+ diffPromise,
1111
+ getAuthenticatedUser(config)
1037
1112
  ]);
1038
1113
  spinner.stop();
1039
- const reviewSpinner = ora2("Reviewing changes\u2026").start();
1114
+ const branch = await getCurrentBranch();
1115
+ const isSelfSubmit = issue.author !== null && issue.author === me;
1040
1116
  let review = "";
1041
- try {
1042
- review = await reviewChanges(config, issueNumber, issue, diff);
1043
- } catch (err) {
1044
- review = `(Review failed: ${err.message})`;
1117
+ if (!isSelfSubmit) {
1118
+ const reviewSpinner = ora2("Reviewing changes\u2026").start();
1119
+ try {
1120
+ review = await reviewChanges(config, issueNumber, issue, diff);
1121
+ } catch (err) {
1122
+ review = `(Review failed: ${err.message})`;
1123
+ }
1124
+ reviewSpinner.stop();
1045
1125
  }
1046
- reviewSpinner.stop();
1047
1126
  const divider = chalk7.dim("\u2500".repeat(70));
1048
1127
  console.log("\n" + divider);
1049
- console.log(chalk7.bold(` Review \u2014 task #${issueNumber} "${issue.title}"`));
1050
- console.log(divider);
1051
- console.log(renderMarkdown(review));
1128
+ if (isSelfSubmit) {
1129
+ console.log(chalk7.yellow(` Self-submit detected \u2014 AI review skipped.`));
1130
+ } else {
1131
+ console.log(chalk7.bold(` Review \u2014 task #${issueNumber} "${issue.title}"`));
1132
+ console.log(divider);
1133
+ console.log(renderMarkdown(review));
1134
+ }
1052
1135
  console.log(divider + "\n");
1053
1136
  let shouldProceed;
1054
1137
  try {
@@ -1084,15 +1167,15 @@ async function run(_input, config) {
1084
1167
  spinner = ora2("Creating pull request\u2026").start();
1085
1168
  let prUrl;
1086
1169
  try {
1087
- prUrl = await createPR(
1088
- config,
1089
- issue.title,
1090
- `Closes #${issueNumber}
1091
-
1092
- ${issue.body ?? ""}`.trim(),
1093
- branch,
1094
- defaultBranch
1095
- );
1170
+ const prBody = [
1171
+ `Closes #${issueNumber}`,
1172
+ issue.body ? `
1173
+ ${issue.body}` : "",
1174
+ review ? `
1175
+ ## AI Review
1176
+ ${review}` : ""
1177
+ ].join("\n").trim();
1178
+ prUrl = await createPR(config, issue.title, prBody, branch, defaultBranch);
1096
1179
  spinner.stop();
1097
1180
  } catch (err) {
1098
1181
  spinner.stop();
@@ -1106,25 +1189,31 @@ ${issue.body ?? ""}`.trim(),
1106
1189
  spinner.stop();
1107
1190
  return `PR created (${prUrl}) but failed to update label: ${err.message}`;
1108
1191
  }
1192
+ setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
1109
1193
  return `Task #${issueNumber} submitted.
1110
1194
  Commit: "${commitMessage.trim()}"
1111
1195
  PR: ${prUrl}`;
1112
1196
  }
1113
1197
  async function execute(input4, config) {
1114
- const branch = await getCurrentBranch();
1115
- const match = branch.match(/^task-(\d+)-/);
1116
- if (!match) return `Not on a task branch (current: ${branch}). Expected format: task-N-title.`;
1117
- const issueNumber = parseInt(match[1], 10);
1118
- const [issue, defaultBranch, diff] = await Promise.all([
1198
+ const taskState = getConfig().taskState;
1199
+ const issueNumber = taskState?.activeIssueNumber;
1200
+ if (!issueNumber) return "No active task found. Claim a task first.";
1201
+ const diffPromise = taskState?.baseCommit ? getDiffFromCommit(taskState.baseCommit) : getDiff(config.github.baseBranch);
1202
+ const [issue, defaultBranch, diff, branch, me] = await Promise.all([
1119
1203
  getTask(config, issueNumber),
1120
1204
  getBaseBranch(config),
1121
- getDiff(config.github.baseBranch)
1205
+ diffPromise,
1206
+ getCurrentBranch(),
1207
+ getAuthenticatedUser(config)
1122
1208
  ]);
1209
+ const isSelfSubmit = issue.author !== null && issue.author === me;
1123
1210
  let review = "";
1124
- try {
1125
- review = await reviewChanges(config, issueNumber, issue, diff);
1126
- } catch (err) {
1127
- review = `(Review failed: ${err.message})`;
1211
+ if (!isSelfSubmit) {
1212
+ try {
1213
+ review = await reviewChanges(config, issueNumber, issue, diff);
1214
+ } catch (err) {
1215
+ review = `(Review failed: ${err.message})`;
1216
+ }
1128
1217
  }
1129
1218
  const commitMessage = input4["commit_message"]?.trim() || `complete: ${issue.title}`;
1130
1219
  try {
@@ -1134,15 +1223,15 @@ async function execute(input4, config) {
1134
1223
  }
1135
1224
  let prUrl;
1136
1225
  try {
1137
- prUrl = await createPR(
1138
- config,
1139
- issue.title,
1140
- `Closes #${issueNumber}
1141
-
1142
- ${issue.body ?? ""}`.trim(),
1143
- branch,
1144
- defaultBranch
1145
- );
1226
+ const prBody = [
1227
+ `Closes #${issueNumber}`,
1228
+ issue.body ? `
1229
+ ${issue.body}` : "",
1230
+ review ? `
1231
+ ## AI Review
1232
+ ${review}` : ""
1233
+ ].join("\n").trim();
1234
+ prUrl = await createPR(config, issue.title, prBody, branch, defaultBranch);
1146
1235
  } catch (err) {
1147
1236
  return `Committed but PR creation failed: ${err.message}`;
1148
1237
  }
@@ -1336,23 +1425,28 @@ Finish or submit it before claiming a new one.`;
1336
1425
  let spinner = ora4(`Claiming #${issue.number}\u2026`).start();
1337
1426
  await claimTask(config, issue.number, me);
1338
1427
  spinner.stop();
1339
- const branch = makeBranchName(issue.number, me);
1340
- spinner = ora4(`Creating branch ${branch}\u2026`).start();
1428
+ const branch = makeWorkerBranchName(me);
1429
+ spinner = ora4(`Switching to branch ${branch}\u2026`).start();
1430
+ let isNew = false;
1341
1431
  try {
1342
- await createAndSwitchBranch(branch);
1432
+ isNew = await switchToBranchOrCreate(branch);
1343
1433
  spinner.stop();
1344
1434
  } catch {
1345
- spinner.warn(`Could not create branch ${branch}`);
1435
+ spinner.warn(`Could not switch to branch ${branch}`);
1346
1436
  }
1347
- spinner = ora4("Pushing branch\u2026").start();
1348
- try {
1349
- await pushBranch(branch);
1350
- spinner.stop();
1351
- } catch {
1352
- spinner.warn("Could not push branch");
1437
+ if (isNew) {
1438
+ spinner = ora4("Pushing branch\u2026").start();
1439
+ try {
1440
+ await pushBranch(branch);
1441
+ spinner.stop();
1442
+ } catch {
1443
+ spinner.warn("Could not push branch");
1444
+ }
1353
1445
  }
1446
+ const baseCommit = await getCurrentCommit();
1447
+ setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1354
1448
  console.log(chalk8.green(`
1355
- Claimed! Branch: ${branch}
1449
+ Claimed! Branch: ${branch} (base: ${baseCommit.slice(0, 7)})
1356
1450
  `));
1357
1451
  let openClaude;
1358
1452
  try {
@@ -1405,16 +1499,21 @@ async function execute3(input4, config) {
1405
1499
  } catch (err) {
1406
1500
  return `Error claiming task: ${err.message}`;
1407
1501
  }
1408
- const branch = makeBranchName(issueNumber, me);
1502
+ const branch = makeWorkerBranchName(me);
1503
+ let isNew = false;
1409
1504
  try {
1410
- await createAndSwitchBranch(branch);
1505
+ isNew = await switchToBranchOrCreate(branch);
1411
1506
  } catch {
1412
1507
  }
1413
- try {
1414
- await pushBranch(branch);
1415
- } catch {
1508
+ if (isNew) {
1509
+ try {
1510
+ await pushBranch(branch);
1511
+ } catch {
1512
+ }
1416
1513
  }
1417
- return `Task #${issueNumber} claimed. Branch: ${branch}`;
1514
+ const baseCommit = await getCurrentCommit();
1515
+ setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1516
+ return `Task #${issueNumber} claimed. Branch: ${branch} (base commit: ${baseCommit.slice(0, 7)})`;
1418
1517
  }
1419
1518
  return `Unknown action: ${action}`;
1420
1519
  }
@@ -1505,6 +1604,20 @@ var definition4 = {
1505
1604
  }
1506
1605
  };
1507
1606
  async function run4(input4, config) {
1607
+ const authSpinner = ora5("Checking permissions\u2026").start();
1608
+ let me;
1609
+ let allowed;
1610
+ try {
1611
+ me = await getAuthenticatedUser(config);
1612
+ allowed = await isCollaborator(config, me);
1613
+ authSpinner.stop();
1614
+ } catch (err) {
1615
+ authSpinner.stop();
1616
+ return `Error checking permissions: ${err.message}`;
1617
+ }
1618
+ if (!allowed) {
1619
+ return `Permission denied: only repository collaborators can create tasks.`;
1620
+ }
1508
1621
  let title = input4["title"]?.trim();
1509
1622
  if (!title) {
1510
1623
  try {
@@ -1602,6 +1715,10 @@ async function run4(input4, config) {
1602
1715
  return `Created #${issueNumber} "${issueTitle}" \u2014 ${htmlUrl}`;
1603
1716
  }
1604
1717
  async function execute4(input4, config) {
1718
+ const me = await getAuthenticatedUser(config);
1719
+ if (!await isCollaborator(config, me)) {
1720
+ return `Permission denied: only repository collaborators can create tasks.`;
1721
+ }
1605
1722
  const title = input4["title"].trim();
1606
1723
  const feedback = input4["feedback"];
1607
1724
  let guide = await generateGuide(config, title);
@@ -1820,6 +1937,13 @@ var definition9 = {
1820
1937
  };
1821
1938
  async function run9(input4, config) {
1822
1939
  const issueNumber = input4["issue_number"];
1940
+ const [me, issue] = await Promise.all([
1941
+ getAuthenticatedUser(config),
1942
+ getTask(config, issueNumber)
1943
+ ]);
1944
+ if (issue.author && issue.author !== me) {
1945
+ return `Permission denied: only the task author (@${issue.author}) can reject task #${issueNumber}.`;
1946
+ }
1823
1947
  let feedback;
1824
1948
  try {
1825
1949
  feedback = await promptInput3({
@@ -1889,6 +2013,13 @@ async function run9(input4, config) {
1889
2013
  async function execute9(input4, config) {
1890
2014
  const issueNumber = input4["issue_number"];
1891
2015
  const feedback = input4["feedback"];
2016
+ const [me, issue] = await Promise.all([
2017
+ getAuthenticatedUser(config),
2018
+ getTask(config, issueNumber)
2019
+ ]);
2020
+ if (issue.author && issue.author !== me) {
2021
+ return `Permission denied: only the task author (@${issue.author}) can reject task #${issueNumber}.`;
2022
+ }
1892
2023
  let comment;
1893
2024
  try {
1894
2025
  comment = await generateRejectionComment(config, issueNumber, feedback);
@@ -1941,15 +2072,15 @@ var definition10 = {
1941
2072
  async function run10(input4, config) {
1942
2073
  let issueNumber = input4["issue_number"];
1943
2074
  if (!issueNumber) {
1944
- const spinner2 = ora9("Loading tasks for review\u2026").start();
2075
+ const spinner3 = ora9("Loading tasks for review\u2026").start();
1945
2076
  let tasks;
1946
2077
  let me;
1947
2078
  try {
1948
2079
  me = await getAuthenticatedUser(config);
1949
2080
  tasks = await listTasksForReview(config, me);
1950
- spinner2.stop();
2081
+ spinner3.stop();
1951
2082
  } catch (err) {
1952
- spinner2.stop();
2083
+ spinner3.stop();
1953
2084
  return `Error: ${err.message}`;
1954
2085
  }
1955
2086
  if (tasks.length === 0) return "No tasks pending review.";
@@ -1965,6 +2096,22 @@ async function run10(input4, config) {
1965
2096
  return "Cancelled.";
1966
2097
  }
1967
2098
  }
2099
+ const spinner2 = ora9("Verifying permissions\u2026").start();
2100
+ let me2;
2101
+ let issue;
2102
+ try {
2103
+ [me2, issue] = await Promise.all([
2104
+ getAuthenticatedUser(config),
2105
+ getTask(config, issueNumber)
2106
+ ]);
2107
+ spinner2.stop();
2108
+ } catch (err) {
2109
+ spinner2.stop();
2110
+ return `Error: ${err.message}`;
2111
+ }
2112
+ if (issue.author && issue.author !== me2) {
2113
+ return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
2114
+ }
1968
2115
  const baseBranch = config.github.baseBranch ?? "main";
1969
2116
  let confirmed;
1970
2117
  try {
@@ -1993,6 +2140,13 @@ Issue closed.`;
1993
2140
  }
1994
2141
  async function execute10(input4, config) {
1995
2142
  const issueNumber = input4["issue_number"];
2143
+ const [me, issue] = await Promise.all([
2144
+ getAuthenticatedUser(config),
2145
+ getTask(config, issueNumber)
2146
+ ]);
2147
+ if (issue.author && issue.author !== me) {
2148
+ return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
2149
+ }
1996
2150
  const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
1997
2151
  try {
1998
2152
  const result = await acceptTask(config, issueNumber);
package/dist/mcp.js CHANGED
@@ -24,6 +24,7 @@ __export(github_exports, {
24
24
  getBaseBranch: () => getBaseBranch,
25
25
  getDefaultBranch: () => getDefaultBranch,
26
26
  getTask: () => getTask,
27
+ isCollaborator: () => isCollaborator,
27
28
  listComments: () => listComments,
28
29
  listMyTasks: () => listMyTasks,
29
30
  listTasks: () => listTasks,
@@ -50,6 +51,7 @@ function parseIssue(issue) {
50
51
  title: issue.title,
51
52
  body: issue.body ?? null,
52
53
  state: issue.state,
54
+ author: issue.user?.login ?? null,
53
55
  assignee: issue.assignee?.login ?? null,
54
56
  labels: (issue.labels ?? []).map(
55
57
  (l) => typeof l === "string" ? l : l.name ?? ""
@@ -226,6 +228,16 @@ async function getAuthenticatedUser(config) {
226
228
  const { data } = await octokit.users.getAuthenticated();
227
229
  return data.login;
228
230
  }
231
+ async function isCollaborator(config, username) {
232
+ const octokit = createOctokit(config.githubToken);
233
+ const { owner, repo } = config.github;
234
+ try {
235
+ const { data } = await octokit.repos.getCollaboratorPermissionLevel({ owner, repo, username });
236
+ return data.permission === "admin" || data.permission === "write" || data.permission === "maintain";
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
229
241
  async function listMyTasks(config, username) {
230
242
  const octokit = createOctokit(config.githubToken);
231
243
  const { owner, repo } = config.github;
@@ -360,15 +372,63 @@ async function getCurrentBranch() {
360
372
  const summary = await git.branch();
361
373
  return summary.current;
362
374
  }
363
- async function createAndSwitchBranch(name) {
364
- await git.checkoutLocalBranch(name);
365
- }
366
375
  async function pushBranch(name) {
367
376
  await git.push("origin", name, ["--set-upstream"]);
368
377
  }
369
- function makeBranchName(issueNumber, username) {
378
+ function makeWorkerBranchName(username) {
370
379
  const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
371
- return `task-${issueNumber}-${slug}`;
380
+ return `worker-${slug}`;
381
+ }
382
+ async function getCurrentCommit() {
383
+ return (await git.revparse(["HEAD"])).trim();
384
+ }
385
+ async function switchToBranchOrCreate(name) {
386
+ try {
387
+ const branches = await git.branch(["-a"]);
388
+ const exists = Object.keys(branches.branches).some(
389
+ (b) => b === name || b === `remotes/origin/${name}`
390
+ );
391
+ if (exists) {
392
+ await git.checkout(name);
393
+ return false;
394
+ }
395
+ await git.checkoutLocalBranch(name);
396
+ return true;
397
+ } catch {
398
+ await git.checkoutLocalBranch(name);
399
+ return true;
400
+ }
401
+ }
402
+ async function getDiffFromCommit(baseCommit) {
403
+ const status = await git.status();
404
+ const parts = [];
405
+ const fileLines = [
406
+ ...status.modified.map((f) => ` M ${f}`),
407
+ ...status.created.map((f) => ` A ${f}`),
408
+ ...status.deleted.map((f) => ` D ${f}`),
409
+ ...status.renamed.map((f) => ` R ${f.from} \u2192 ${f.to}`),
410
+ ...status.not_added.map((f) => ` ? ${f}`)
411
+ ];
412
+ if (fileLines.length > 0) {
413
+ parts.push("## Uncommitted changes\n" + fileLines.join("\n"));
414
+ const uncommitted = await git.diff(["HEAD"]);
415
+ if (uncommitted) {
416
+ const capped = uncommitted.length > 8e3 ? uncommitted.slice(0, 8e3) + "\n... (truncated)" : uncommitted;
417
+ parts.push("## Uncommitted diff\n```diff\n" + capped + "\n```");
418
+ }
419
+ }
420
+ const log = await git.log({ from: baseCommit, to: "HEAD" });
421
+ if (log.total > 0) {
422
+ const logLines = log.all.map((c) => ` ${c.hash.slice(0, 7)} ${c.message}`);
423
+ parts.push(`## Commits since task claimed (${log.total} total)
424
+ ` + logLines.join("\n"));
425
+ const branchDiff = await git.diff([baseCommit, "HEAD"]);
426
+ if (branchDiff) {
427
+ const capped = branchDiff.length > 12e3 ? branchDiff.slice(0, 12e3) + "\n... (truncated)" : branchDiff;
428
+ parts.push("## Full diff since task claimed\n```diff\n" + capped + "\n```");
429
+ }
430
+ }
431
+ return parts.length > 0 ? parts.join("\n\n") : "No changes since task was claimed.";
372
432
  }
373
433
  async function findMergeBase(configuredBase) {
374
434
  const candidates = configuredBase ? [`origin/${configuredBase}`, "origin/main", "origin/master"] : ["origin/main", "origin/master"];
@@ -428,6 +488,69 @@ async function stageAllAndCommit(message) {
428
488
  await git.push("origin", branch, ["--set-upstream"]);
429
489
  }
430
490
 
491
+ // src/lib/config.ts
492
+ import Conf from "conf";
493
+ import { z } from "zod";
494
+ var configSchema = z.object({
495
+ aiApiKey: z.string().min(1),
496
+ aiBaseUrl: z.string().optional(),
497
+ aiModel: z.string().optional(),
498
+ githubToken: z.string().min(1),
499
+ githubClientId: z.string().optional(),
500
+ github: z.object({
501
+ owner: z.string().min(1),
502
+ repo: z.string().min(1),
503
+ baseBranch: z.string().optional()
504
+ }),
505
+ taskState: z.object({
506
+ activeIssueNumber: z.number().optional(),
507
+ baseCommit: z.string().optional()
508
+ }).optional()
509
+ });
510
+ var store = new Conf({
511
+ projectName: "techunter",
512
+ defaults: {}
513
+ });
514
+ function getConfig() {
515
+ const raw = store.store;
516
+ const result = configSchema.safeParse(raw);
517
+ if (!result.success) {
518
+ throw new Error("Configuration is missing or invalid.");
519
+ }
520
+ return result.data;
521
+ }
522
+ function setConfig(partial) {
523
+ const current = store.store;
524
+ if (partial.github) {
525
+ current["github"] = {
526
+ ...current["github"] ?? {},
527
+ ...partial.github
528
+ };
529
+ }
530
+ if (partial.aiApiKey !== void 0) {
531
+ current["aiApiKey"] = partial.aiApiKey;
532
+ }
533
+ if (partial.aiBaseUrl !== void 0) {
534
+ current["aiBaseUrl"] = partial.aiBaseUrl;
535
+ }
536
+ if (partial.aiModel !== void 0) {
537
+ current["aiModel"] = partial.aiModel;
538
+ }
539
+ if (partial.githubToken !== void 0) {
540
+ current["githubToken"] = partial.githubToken;
541
+ }
542
+ if (partial.githubClientId !== void 0) {
543
+ current["githubClientId"] = partial.githubClientId;
544
+ }
545
+ if (partial.taskState !== void 0) {
546
+ current["taskState"] = {
547
+ ...current["taskState"] ?? {},
548
+ ...partial.taskState
549
+ };
550
+ }
551
+ store.store = current;
552
+ }
553
+
431
554
  // src/lib/markdown.ts
432
555
  import { marked } from "marked";
433
556
  import { markedTerminal } from "marked-terminal";
@@ -635,7 +758,7 @@ async function runSubAgentLoop(config, systemPrompt, userMessage, toolNames) {
635
758
  }
636
759
 
637
760
  // src/tools/submit/prompts.ts
638
- var REVIEWER_SYSTEM_PROMPT = "You are a concise code reviewer. Use run_command to run tests/lint if needed, and read_file to inspect specific files. Then output your review: for each acceptance criterion mark \u2705 met or \u274C not met with a one-line reason. End with an overall verdict line: Ready to submit / Not ready. Reply in the same language as the task.";
761
+ var REVIEWER_SYSTEM_PROMPT = "You are a concise code reviewer. The diff provided shows all changes on this worker branch since the task was claimed. The branch is shared across tasks, so the diff may contain changes unrelated to this specific task \u2014 use the task title and acceptance criteria to identify which changes are relevant, and ignore the rest. Use run_command to run tests/lint if needed, and read_file to inspect specific files. Then output your review: for each acceptance criterion mark \u2705 met or \u274C not met with a one-line reason. End with an overall verdict line: Ready to submit / Not ready. Reply in the same language as the task.";
639
762
 
640
763
  // src/tools/submit/reviewer.ts
641
764
  async function reviewChanges(config, issueNumber, issue, diff) {
@@ -669,32 +792,41 @@ var definition = {
669
792
  }
670
793
  };
671
794
  async function run(_input, config) {
672
- const branch = await getCurrentBranch();
673
- const match = branch.match(/^task-(\d+)-/);
674
- if (!match) {
675
- return `Not on a task branch (current: ${branch}). Expected format: task-N-title.`;
795
+ const taskState = getConfig().taskState;
796
+ const issueNumber = taskState?.activeIssueNumber;
797
+ if (!issueNumber) {
798
+ return "No active task found. Claim a task first with /pick.";
676
799
  }
677
- const issueNumber = parseInt(match[1], 10);
678
800
  let spinner = ora("Loading task and diff\u2026").start();
679
- const [issue, defaultBranch, diff] = await Promise.all([
801
+ const diffPromise = taskState?.baseCommit ? getDiffFromCommit(taskState.baseCommit) : getDiff(config.github.baseBranch);
802
+ const [issue, defaultBranch, diff, me] = await Promise.all([
680
803
  getTask(config, issueNumber),
681
804
  getBaseBranch(config),
682
- getDiff(config.github.baseBranch)
805
+ diffPromise,
806
+ getAuthenticatedUser(config)
683
807
  ]);
684
808
  spinner.stop();
685
- const reviewSpinner = ora("Reviewing changes\u2026").start();
809
+ const branch = await getCurrentBranch();
810
+ const isSelfSubmit = issue.author !== null && issue.author === me;
686
811
  let review = "";
687
- try {
688
- review = await reviewChanges(config, issueNumber, issue, diff);
689
- } catch (err) {
690
- review = `(Review failed: ${err.message})`;
812
+ if (!isSelfSubmit) {
813
+ const reviewSpinner = ora("Reviewing changes\u2026").start();
814
+ try {
815
+ review = await reviewChanges(config, issueNumber, issue, diff);
816
+ } catch (err) {
817
+ review = `(Review failed: ${err.message})`;
818
+ }
819
+ reviewSpinner.stop();
691
820
  }
692
- reviewSpinner.stop();
693
821
  const divider = chalk5.dim("\u2500".repeat(70));
694
822
  console.log("\n" + divider);
695
- console.log(chalk5.bold(` Review \u2014 task #${issueNumber} "${issue.title}"`));
696
- console.log(divider);
697
- console.log(renderMarkdown(review));
823
+ if (isSelfSubmit) {
824
+ console.log(chalk5.yellow(` Self-submit detected \u2014 AI review skipped.`));
825
+ } else {
826
+ console.log(chalk5.bold(` Review \u2014 task #${issueNumber} "${issue.title}"`));
827
+ console.log(divider);
828
+ console.log(renderMarkdown(review));
829
+ }
698
830
  console.log(divider + "\n");
699
831
  let shouldProceed;
700
832
  try {
@@ -730,15 +862,15 @@ async function run(_input, config) {
730
862
  spinner = ora("Creating pull request\u2026").start();
731
863
  let prUrl;
732
864
  try {
733
- prUrl = await createPR(
734
- config,
735
- issue.title,
736
- `Closes #${issueNumber}
737
-
738
- ${issue.body ?? ""}`.trim(),
739
- branch,
740
- defaultBranch
741
- );
865
+ const prBody = [
866
+ `Closes #${issueNumber}`,
867
+ issue.body ? `
868
+ ${issue.body}` : "",
869
+ review ? `
870
+ ## AI Review
871
+ ${review}` : ""
872
+ ].join("\n").trim();
873
+ prUrl = await createPR(config, issue.title, prBody, branch, defaultBranch);
742
874
  spinner.stop();
743
875
  } catch (err) {
744
876
  spinner.stop();
@@ -752,25 +884,31 @@ ${issue.body ?? ""}`.trim(),
752
884
  spinner.stop();
753
885
  return `PR created (${prUrl}) but failed to update label: ${err.message}`;
754
886
  }
887
+ setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
755
888
  return `Task #${issueNumber} submitted.
756
889
  Commit: "${commitMessage.trim()}"
757
890
  PR: ${prUrl}`;
758
891
  }
759
892
  async function execute(input, config) {
760
- const branch = await getCurrentBranch();
761
- const match = branch.match(/^task-(\d+)-/);
762
- if (!match) return `Not on a task branch (current: ${branch}). Expected format: task-N-title.`;
763
- const issueNumber = parseInt(match[1], 10);
764
- const [issue, defaultBranch, diff] = await Promise.all([
893
+ const taskState = getConfig().taskState;
894
+ const issueNumber = taskState?.activeIssueNumber;
895
+ if (!issueNumber) return "No active task found. Claim a task first.";
896
+ const diffPromise = taskState?.baseCommit ? getDiffFromCommit(taskState.baseCommit) : getDiff(config.github.baseBranch);
897
+ const [issue, defaultBranch, diff, branch, me] = await Promise.all([
765
898
  getTask(config, issueNumber),
766
899
  getBaseBranch(config),
767
- getDiff(config.github.baseBranch)
900
+ diffPromise,
901
+ getCurrentBranch(),
902
+ getAuthenticatedUser(config)
768
903
  ]);
904
+ const isSelfSubmit = issue.author !== null && issue.author === me;
769
905
  let review = "";
770
- try {
771
- review = await reviewChanges(config, issueNumber, issue, diff);
772
- } catch (err) {
773
- review = `(Review failed: ${err.message})`;
906
+ if (!isSelfSubmit) {
907
+ try {
908
+ review = await reviewChanges(config, issueNumber, issue, diff);
909
+ } catch (err) {
910
+ review = `(Review failed: ${err.message})`;
911
+ }
774
912
  }
775
913
  const commitMessage = input["commit_message"]?.trim() || `complete: ${issue.title}`;
776
914
  try {
@@ -780,15 +918,15 @@ async function execute(input, config) {
780
918
  }
781
919
  let prUrl;
782
920
  try {
783
- prUrl = await createPR(
784
- config,
785
- issue.title,
786
- `Closes #${issueNumber}
787
-
788
- ${issue.body ?? ""}`.trim(),
789
- branch,
790
- defaultBranch
791
- );
921
+ const prBody = [
922
+ `Closes #${issueNumber}`,
923
+ issue.body ? `
924
+ ${issue.body}` : "",
925
+ review ? `
926
+ ## AI Review
927
+ ${review}` : ""
928
+ ].join("\n").trim();
929
+ prUrl = await createPR(config, issue.title, prBody, branch, defaultBranch);
792
930
  } catch (err) {
793
931
  return `Committed but PR creation failed: ${err.message}`;
794
932
  }
@@ -982,23 +1120,28 @@ Finish or submit it before claiming a new one.`;
982
1120
  let spinner = ora3(`Claiming #${issue.number}\u2026`).start();
983
1121
  await claimTask(config, issue.number, me);
984
1122
  spinner.stop();
985
- const branch = makeBranchName(issue.number, me);
986
- spinner = ora3(`Creating branch ${branch}\u2026`).start();
1123
+ const branch = makeWorkerBranchName(me);
1124
+ spinner = ora3(`Switching to branch ${branch}\u2026`).start();
1125
+ let isNew = false;
987
1126
  try {
988
- await createAndSwitchBranch(branch);
1127
+ isNew = await switchToBranchOrCreate(branch);
989
1128
  spinner.stop();
990
1129
  } catch {
991
- spinner.warn(`Could not create branch ${branch}`);
1130
+ spinner.warn(`Could not switch to branch ${branch}`);
992
1131
  }
993
- spinner = ora3("Pushing branch\u2026").start();
994
- try {
995
- await pushBranch(branch);
996
- spinner.stop();
997
- } catch {
998
- spinner.warn("Could not push branch");
1132
+ if (isNew) {
1133
+ spinner = ora3("Pushing branch\u2026").start();
1134
+ try {
1135
+ await pushBranch(branch);
1136
+ spinner.stop();
1137
+ } catch {
1138
+ spinner.warn("Could not push branch");
1139
+ }
999
1140
  }
1141
+ const baseCommit = await getCurrentCommit();
1142
+ setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1000
1143
  console.log(chalk6.green(`
1001
- Claimed! Branch: ${branch}
1144
+ Claimed! Branch: ${branch} (base: ${baseCommit.slice(0, 7)})
1002
1145
  `));
1003
1146
  let openClaude;
1004
1147
  try {
@@ -1051,16 +1194,21 @@ async function execute3(input, config) {
1051
1194
  } catch (err) {
1052
1195
  return `Error claiming task: ${err.message}`;
1053
1196
  }
1054
- const branch = makeBranchName(issueNumber, me);
1197
+ const branch = makeWorkerBranchName(me);
1198
+ let isNew = false;
1055
1199
  try {
1056
- await createAndSwitchBranch(branch);
1200
+ isNew = await switchToBranchOrCreate(branch);
1057
1201
  } catch {
1058
1202
  }
1059
- try {
1060
- await pushBranch(branch);
1061
- } catch {
1203
+ if (isNew) {
1204
+ try {
1205
+ await pushBranch(branch);
1206
+ } catch {
1207
+ }
1062
1208
  }
1063
- return `Task #${issueNumber} claimed. Branch: ${branch}`;
1209
+ const baseCommit = await getCurrentCommit();
1210
+ setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1211
+ return `Task #${issueNumber} claimed. Branch: ${branch} (base commit: ${baseCommit.slice(0, 7)})`;
1064
1212
  }
1065
1213
  return `Unknown action: ${action}`;
1066
1214
  }
@@ -1151,6 +1299,20 @@ var definition4 = {
1151
1299
  }
1152
1300
  };
1153
1301
  async function run4(input, config) {
1302
+ const authSpinner = ora4("Checking permissions\u2026").start();
1303
+ let me;
1304
+ let allowed;
1305
+ try {
1306
+ me = await getAuthenticatedUser(config);
1307
+ allowed = await isCollaborator(config, me);
1308
+ authSpinner.stop();
1309
+ } catch (err) {
1310
+ authSpinner.stop();
1311
+ return `Error checking permissions: ${err.message}`;
1312
+ }
1313
+ if (!allowed) {
1314
+ return `Permission denied: only repository collaborators can create tasks.`;
1315
+ }
1154
1316
  let title = input["title"]?.trim();
1155
1317
  if (!title) {
1156
1318
  try {
@@ -1248,6 +1410,10 @@ async function run4(input, config) {
1248
1410
  return `Created #${issueNumber} "${issueTitle}" \u2014 ${htmlUrl}`;
1249
1411
  }
1250
1412
  async function execute4(input, config) {
1413
+ const me = await getAuthenticatedUser(config);
1414
+ if (!await isCollaborator(config, me)) {
1415
+ return `Permission denied: only repository collaborators can create tasks.`;
1416
+ }
1251
1417
  const title = input["title"].trim();
1252
1418
  const feedback = input["feedback"];
1253
1419
  let guide = await generateGuide(config, title);
@@ -1466,6 +1632,13 @@ var definition9 = {
1466
1632
  };
1467
1633
  async function run9(input, config) {
1468
1634
  const issueNumber = input["issue_number"];
1635
+ const [me, issue] = await Promise.all([
1636
+ getAuthenticatedUser(config),
1637
+ getTask(config, issueNumber)
1638
+ ]);
1639
+ if (issue.author && issue.author !== me) {
1640
+ return `Permission denied: only the task author (@${issue.author}) can reject task #${issueNumber}.`;
1641
+ }
1469
1642
  let feedback;
1470
1643
  try {
1471
1644
  feedback = await promptInput3({
@@ -1535,6 +1708,13 @@ async function run9(input, config) {
1535
1708
  async function execute9(input, config) {
1536
1709
  const issueNumber = input["issue_number"];
1537
1710
  const feedback = input["feedback"];
1711
+ const [me, issue] = await Promise.all([
1712
+ getAuthenticatedUser(config),
1713
+ getTask(config, issueNumber)
1714
+ ]);
1715
+ if (issue.author && issue.author !== me) {
1716
+ return `Permission denied: only the task author (@${issue.author}) can reject task #${issueNumber}.`;
1717
+ }
1538
1718
  let comment;
1539
1719
  try {
1540
1720
  comment = await generateRejectionComment(config, issueNumber, feedback);
@@ -1587,15 +1767,15 @@ var definition10 = {
1587
1767
  async function run10(input, config) {
1588
1768
  let issueNumber = input["issue_number"];
1589
1769
  if (!issueNumber) {
1590
- const spinner2 = ora8("Loading tasks for review\u2026").start();
1770
+ const spinner3 = ora8("Loading tasks for review\u2026").start();
1591
1771
  let tasks;
1592
1772
  let me;
1593
1773
  try {
1594
1774
  me = await getAuthenticatedUser(config);
1595
1775
  tasks = await listTasksForReview(config, me);
1596
- spinner2.stop();
1776
+ spinner3.stop();
1597
1777
  } catch (err) {
1598
- spinner2.stop();
1778
+ spinner3.stop();
1599
1779
  return `Error: ${err.message}`;
1600
1780
  }
1601
1781
  if (tasks.length === 0) return "No tasks pending review.";
@@ -1611,6 +1791,22 @@ async function run10(input, config) {
1611
1791
  return "Cancelled.";
1612
1792
  }
1613
1793
  }
1794
+ const spinner2 = ora8("Verifying permissions\u2026").start();
1795
+ let me2;
1796
+ let issue;
1797
+ try {
1798
+ [me2, issue] = await Promise.all([
1799
+ getAuthenticatedUser(config),
1800
+ getTask(config, issueNumber)
1801
+ ]);
1802
+ spinner2.stop();
1803
+ } catch (err) {
1804
+ spinner2.stop();
1805
+ return `Error: ${err.message}`;
1806
+ }
1807
+ if (issue.author && issue.author !== me2) {
1808
+ return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
1809
+ }
1614
1810
  const baseBranch = config.github.baseBranch ?? "main";
1615
1811
  let confirmed;
1616
1812
  try {
@@ -1639,6 +1835,13 @@ Issue closed.`;
1639
1835
  }
1640
1836
  async function execute10(input, config) {
1641
1837
  const issueNumber = input["issue_number"];
1838
+ const [me, issue] = await Promise.all([
1839
+ getAuthenticatedUser(config),
1840
+ getTask(config, issueNumber)
1841
+ ]);
1842
+ if (issue.author && issue.author !== me) {
1843
+ return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
1844
+ }
1642
1845
  const spinner = ora8(`Merging PR for #${issueNumber}\u2026`).start();
1643
1846
  try {
1644
1847
  const result = await acceptTask(config, issueNumber);
@@ -2240,34 +2443,6 @@ var toolModules = [
2240
2443
  ask_user_exports
2241
2444
  ];
2242
2445
 
2243
- // src/lib/config.ts
2244
- import Conf from "conf";
2245
- import { z } from "zod";
2246
- var configSchema = z.object({
2247
- aiApiKey: z.string().min(1),
2248
- aiBaseUrl: z.string().optional(),
2249
- aiModel: z.string().optional(),
2250
- githubToken: z.string().min(1),
2251
- githubClientId: z.string().optional(),
2252
- github: z.object({
2253
- owner: z.string().min(1),
2254
- repo: z.string().min(1),
2255
- baseBranch: z.string().optional()
2256
- })
2257
- });
2258
- var store = new Conf({
2259
- projectName: "techunter",
2260
- defaults: {}
2261
- });
2262
- function getConfig() {
2263
- const raw = store.store;
2264
- const result = configSchema.safeParse(raw);
2265
- if (!result.success) {
2266
- throw new Error("Configuration is missing or invalid.");
2267
- }
2268
- return result.data;
2269
- }
2270
-
2271
2446
  // src/mcp.ts
2272
2447
  var tools = toolModules.filter((m) => m.definition.function.name !== "ask_user");
2273
2448
  var server = new Server(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "techunter",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "AI-powered task distribution CLI for development teams",
5
5
  "author": "Techunter Contributors",
6
6
  "license": "MIT",