techunter 0.1.10 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +237 -209
  2. package/dist/index.js +1913 -1109
  3. package/dist/mcp.js +1228 -557
  4. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -39,12 +39,20 @@ __export(github_exports, {
39
39
  createTask: () => createTask,
40
40
  editTask: () => editTask,
41
41
  embedBaseCommit: () => embedBaseCommit,
42
+ embedTargetBranch: () => embedTargetBranch,
42
43
  ensureLabels: () => ensureLabels,
44
+ ensureRemoteBranch: () => ensureRemoteBranch,
43
45
  extractBaseCommit: () => extractBaseCommit,
46
+ extractTargetBranch: () => extractTargetBranch,
44
47
  formatGuideAsMarkdown: () => formatGuideAsMarkdown,
45
48
  getAuthenticatedUser: () => getAuthenticatedUser,
46
49
  getDefaultBranch: () => getDefaultBranch,
50
+ getIssueNumberFromBranch: () => getIssueNumberFromBranch,
51
+ getOpenSubtasks: () => getOpenSubtasks,
52
+ getRepoFile: () => getRepoFile,
47
53
  getTask: () => getTask,
54
+ getTaskPR: () => getTaskPR,
55
+ getTaskPRDiff: () => getTaskPRDiff,
48
56
  isCollaborator: () => isCollaborator,
49
57
  listComments: () => listComments,
50
58
  listMyTasks: () => listMyTasks,
@@ -54,7 +62,8 @@ __export(github_exports, {
54
62
  mergeWorkerIntoBase: () => mergeWorkerIntoBase,
55
63
  postComment: () => postComment,
56
64
  postGuideComment: () => postGuideComment,
57
- rejectTask: () => rejectTask
65
+ rejectTask: () => rejectTask,
66
+ upsertRepoFile: () => upsertRepoFile
58
67
  });
59
68
  import { Octokit } from "@octokit/rest";
60
69
  import { fetch as undiciFetch } from "undici";
@@ -117,11 +126,22 @@ function extractBaseCommit(body) {
117
126
  const match = body.match(/<!-- techunter-base:([a-f0-9]{7,40}) -->/);
118
127
  return match?.[1] ?? null;
119
128
  }
120
- async function createTask(config, title, body, baseCommit) {
129
+ function embedTargetBranch(body, branch) {
130
+ return `${body}
131
+ ${TARGET_BRANCH_MARKER}${branch} -->`;
132
+ }
133
+ function extractTargetBranch(body) {
134
+ if (!body) return null;
135
+ const match = body.match(/<!-- techunter-target:([^\s>]+) -->/);
136
+ return match?.[1] ?? null;
137
+ }
138
+ async function createTask(config, title, body, baseCommit, targetBranch) {
121
139
  const octokit = createOctokit(config.githubToken);
122
140
  const { owner, repo } = config.github;
123
141
  await ensureLabels(config);
124
- const finalBody = baseCommit ? embedBaseCommit(body ?? "", baseCommit) : body;
142
+ let finalBody = body ?? "";
143
+ if (baseCommit) finalBody = embedBaseCommit(finalBody, baseCommit);
144
+ if (targetBranch) finalBody = embedTargetBranch(finalBody, targetBranch);
125
145
  const { data } = await octokit.issues.create({
126
146
  owner,
127
147
  repo,
@@ -134,13 +154,22 @@ async function createTask(config, title, body, baseCommit) {
134
154
  async function mergeWorkerIntoBase(config, workerBranch, baseBranch) {
135
155
  const octokit = createOctokit(config.githubToken);
136
156
  const { owner, repo } = config.github;
137
- await octokit.repos.merge({
138
- owner,
139
- repo,
140
- base: baseBranch,
141
- head: workerBranch,
142
- commit_message: `chore: merge ${workerBranch} into ${baseBranch}`
143
- });
157
+ try {
158
+ await octokit.repos.merge({
159
+ owner,
160
+ repo,
161
+ base: baseBranch,
162
+ head: workerBranch,
163
+ commit_message: `chore: merge ${workerBranch} into ${baseBranch}`
164
+ });
165
+ } catch (err) {
166
+ if (err.status === 409) {
167
+ throw new Error(
168
+ `Merge conflict: ${workerBranch} cannot be merged into ${baseBranch} cleanly. Resolve conflicts manually.`
169
+ );
170
+ }
171
+ throw err;
172
+ }
144
173
  }
145
174
  async function claimTask(config, number, username) {
146
175
  const octokit = createOctokit(config.githubToken);
@@ -216,6 +245,23 @@ async function postGuideComment(config, number, guide) {
216
245
  body
217
246
  });
218
247
  }
248
+ async function ensureRemoteBranch(config, branchName, fallbackBase) {
249
+ const octokit = createOctokit(config.githubToken);
250
+ const { owner, repo } = config.github;
251
+ try {
252
+ await octokit.repos.getBranch({ owner, repo, branch: branchName });
253
+ return;
254
+ } catch (err) {
255
+ if (err.status !== 404) throw err;
256
+ }
257
+ const { data: baseRef } = await octokit.repos.getBranch({ owner, repo, branch: fallbackBase });
258
+ await octokit.git.createRef({
259
+ owner,
260
+ repo,
261
+ ref: `refs/heads/${branchName}`,
262
+ sha: baseRef.commit.sha
263
+ });
264
+ }
219
265
  async function createPR(config, title, body, branch, base) {
220
266
  const octokit = createOctokit(config.githubToken);
221
267
  const { owner, repo } = config.github;
@@ -232,14 +278,11 @@ async function createPR(config, title, body, branch, base) {
232
278
  async function markInReview(config, number) {
233
279
  const octokit = createOctokit(config.githubToken);
234
280
  const { owner, repo } = config.github;
235
- try {
236
- await octokit.issues.removeLabel({
237
- owner,
238
- repo,
239
- issue_number: number,
240
- name: LABEL_CLAIMED
241
- });
242
- } catch {
281
+ for (const label of [LABEL_CLAIMED, LABEL_CHANGES_NEEDED]) {
282
+ try {
283
+ await octokit.issues.removeLabel({ owner, repo, issue_number: number, name: label });
284
+ } catch {
285
+ }
243
286
  }
244
287
  await octokit.issues.addLabels({
245
288
  owner,
@@ -354,28 +397,115 @@ async function editTask(config, number, title, body) {
354
397
  const { owner, repo } = config.github;
355
398
  await octokit.issues.update({ owner, repo, issue_number: number, title, body });
356
399
  }
400
+ async function upsertRepoFile(config, filePath, content, message) {
401
+ const octokit = createOctokit(config.githubToken);
402
+ const { owner, repo } = config.github;
403
+ let sha;
404
+ try {
405
+ const { data: data2 } = await octokit.repos.getContent({ owner, repo, path: filePath });
406
+ if (!Array.isArray(data2) && data2.type === "file") {
407
+ sha = data2.sha;
408
+ }
409
+ } catch {
410
+ }
411
+ const { data } = await octokit.repos.createOrUpdateFileContents({
412
+ owner,
413
+ repo,
414
+ path: filePath,
415
+ message,
416
+ content: Buffer.from(content, "utf-8").toString("base64"),
417
+ ...sha ? { sha } : {}
418
+ });
419
+ return data.content?.html_url ?? `https://github.com/${owner}/${repo}/blob/main/${filePath}`;
420
+ }
421
+ async function getRepoFile(config, filePath) {
422
+ const octokit = createOctokit(config.githubToken);
423
+ const { owner, repo } = config.github;
424
+ try {
425
+ const { data } = await octokit.repos.getContent({ owner, repo, path: filePath });
426
+ if (!Array.isArray(data) && data.type === "file" && "content" in data) {
427
+ return Buffer.from(data.content, "base64").toString("utf-8");
428
+ }
429
+ return null;
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
357
434
  async function getDefaultBranch(config) {
358
435
  const octokit = createOctokit(config.githubToken);
359
436
  const { owner, repo } = config.github;
360
437
  const { data } = await octokit.repos.get({ owner, repo });
361
438
  return data.default_branch;
362
439
  }
363
- async function acceptTask(config, issueNumber, headBranch) {
440
+ async function getTaskPR(config, issueNumber) {
364
441
  const octokit = createOctokit(config.githubToken);
365
442
  const { owner, repo } = config.github;
366
443
  const { data: prs } = await octokit.pulls.list({ owner, repo, state: "open", per_page: 100 });
367
- const pr = headBranch ? prs.find((p) => p.head.ref === headBranch) : prs.find((p) => p.head.ref.startsWith(`task-${issueNumber}-`) || p.head.ref.startsWith("worker-"));
368
- if (!pr) throw new Error(`No open PR found for task #${issueNumber}`);
369
- const { data: merge } = await octokit.pulls.merge({
444
+ const pr = prs.find(
445
+ (p) => new RegExp(`Closes #${issueNumber}\\b`, "i").test(p.body ?? "")
446
+ );
447
+ if (!pr) return null;
448
+ return { number: pr.number, url: pr.html_url, body: pr.body ?? "", baseBranch: pr.base.ref };
449
+ }
450
+ async function getOpenSubtasks(config, targetBranch) {
451
+ const octokit = createOctokit(config.githubToken);
452
+ const { owner, repo } = config.github;
453
+ const { data } = await octokit.issues.listForRepo({
454
+ owner,
455
+ repo,
456
+ state: "open",
457
+ per_page: 100
458
+ });
459
+ return data.filter((issue) => !issue.pull_request).filter((issue) => extractTargetBranch(issue.body ?? null) === targetBranch).map((issue) => issue.number);
460
+ }
461
+ async function getIssueNumberFromBranch(config, branch) {
462
+ const octokit = createOctokit(config.githubToken);
463
+ const { owner, repo } = config.github;
464
+ const { data: prs } = await octokit.pulls.list({ owner, repo, state: "open", per_page: 100 });
465
+ const pr = prs.find((p) => p.head.ref === branch);
466
+ if (!pr) return null;
467
+ const match = (pr.body ?? "").match(/Closes #(\d+)/i);
468
+ if (!match) return null;
469
+ return { issueNumber: parseInt(match[1], 10), prUrl: pr.html_url };
470
+ }
471
+ async function getTaskPRDiff(config, prNumber) {
472
+ const octokit = createOctokit(config.githubToken);
473
+ const { owner, repo } = config.github;
474
+ const response = await octokit.pulls.get({
370
475
  owner,
371
476
  repo,
372
- pull_number: pr.number,
373
- merge_method: "merge"
477
+ pull_number: prNumber,
478
+ mediaType: { format: "diff" }
374
479
  });
375
- await closeTask(config, issueNumber);
376
- return { prNumber: pr.number, prUrl: pr.html_url, sha: merge.sha ?? "" };
480
+ return response.data;
481
+ }
482
+ async function acceptTask(config, issueNumber) {
483
+ const octokit = createOctokit(config.githubToken);
484
+ const { owner, repo } = config.github;
485
+ const { data: prs } = await octokit.pulls.list({ owner, repo, state: "open", per_page: 100 });
486
+ const pr = prs.find(
487
+ (p) => new RegExp(`Closes #${issueNumber}\\b`, "i").test(p.body ?? "")
488
+ );
489
+ if (!pr) throw new Error(`No open PR found for task #${issueNumber}`);
490
+ try {
491
+ const { data: merge } = await octokit.pulls.merge({
492
+ owner,
493
+ repo,
494
+ pull_number: pr.number,
495
+ merge_method: "merge"
496
+ });
497
+ await closeTask(config, issueNumber);
498
+ return { prNumber: pr.number, prUrl: pr.html_url, sha: merge.sha ?? "", baseBranch: pr.base.ref };
499
+ } catch (err) {
500
+ if (err.status === 405) {
501
+ throw new Error(
502
+ `PR #${pr.number} cannot be merged \u2014 may have conflicts or is not in a mergeable state.`
503
+ );
504
+ }
505
+ throw err;
506
+ }
377
507
  }
378
- var LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED, LABELS, TECHUNTER_LABELS, BASE_COMMIT_MARKER;
508
+ var LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED, LABELS, TECHUNTER_LABELS, BASE_COMMIT_MARKER, TARGET_BRANCH_MARKER;
379
509
  var init_github = __esm({
380
510
  "src/lib/github.ts"() {
381
511
  "use strict";
@@ -392,19 +522,20 @@ var init_github = __esm({
392
522
  ];
393
523
  TECHUNTER_LABELS = /* @__PURE__ */ new Set([LABEL_AVAILABLE, LABEL_CLAIMED, LABEL_IN_REVIEW, LABEL_CHANGES_NEEDED]);
394
524
  BASE_COMMIT_MARKER = "<!-- techunter-base:";
525
+ TARGET_BRANCH_MARKER = "<!-- techunter-target:";
395
526
  }
396
527
  });
397
528
 
398
529
  // src/index.ts
399
- import chalk14 from "chalk";
530
+ import chalk17 from "chalk";
400
531
  import readline from "readline";
401
532
  import { createRequire } from "module";
402
533
 
403
534
  // src/commands/init.ts
404
- import { input, password, select } from "@inquirer/prompts";
405
- import chalk2 from "chalk";
406
- import ora from "ora";
407
- import open from "open";
535
+ import { input, password, select as select11 } from "@inquirer/prompts";
536
+ import chalk13 from "chalk";
537
+ import ora14 from "ora";
538
+ import open2 from "open";
408
539
  import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
409
540
 
410
541
  // src/lib/config.ts
@@ -423,7 +554,8 @@ var configSchema = z.object({
423
554
  }),
424
555
  taskState: z.object({
425
556
  activeIssueNumber: z.number().optional(),
426
- baseCommit: z.string().optional()
557
+ baseCommit: z.string().optional(),
558
+ activeBranch: z.string().optional()
427
559
  }).optional()
428
560
  });
429
561
  var store = new Conf({
@@ -510,14 +642,21 @@ function parseOwnerRepo(remoteUrl) {
510
642
  }
511
643
  return null;
512
644
  }
513
- function makeBranchName(issueNumber, username) {
514
- const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
515
- return `task-${issueNumber}-${slug}`;
516
- }
517
645
  function makeWorkerBranchName(username) {
518
646
  const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
519
647
  return `worker-${slug}`;
520
648
  }
649
+ function makeTaskBranchName(issueNumber, username) {
650
+ const slug = username.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "user";
651
+ return `task-${issueNumber}-${slug}`;
652
+ }
653
+ function isTaskBranch(branch) {
654
+ return /^task-\d+-/.test(branch);
655
+ }
656
+ function parseIssueNumberFromBranch(branch) {
657
+ const match = branch.match(/^task-(\d+)-/);
658
+ return match ? parseInt(match[1], 10) : null;
659
+ }
521
660
  async function getCurrentCommit() {
522
661
  return (await git.revparse(["HEAD"])).trim();
523
662
  }
@@ -638,16 +777,27 @@ async function getRemoteHeadSha(baseBranch) {
638
777
  await git.fetch("origin", baseBranch);
639
778
  return (await git.revparse([`origin/${baseBranch}`])).trim();
640
779
  }
641
- async function resetOrCreateBranch(branchName, sha) {
642
- const branches = await git.branch();
643
- const localExists = Object.keys(branches.branches).some((b) => b === branchName);
644
- if (localExists) {
780
+ async function checkoutFromCommit(branchName, sha) {
781
+ const branches = await git.branch(["-a"]);
782
+ const exists = Object.keys(branches.branches).some(
783
+ (b) => b === branchName || b === `remotes/origin/${branchName}`
784
+ );
785
+ if (exists) {
645
786
  await git.checkout(branchName);
646
- await git.reset(["--hard", sha]);
647
787
  } else {
648
788
  await git.checkoutBranch(branchName, sha);
649
789
  }
650
790
  }
791
+ async function hasUncommittedChanges() {
792
+ const status = await git.status();
793
+ return !status.isClean();
794
+ }
795
+ async function stash(message) {
796
+ await git.stash(["push", "-u", "-m", message]);
797
+ }
798
+ async function stashPop() {
799
+ await git.stash(["pop"]);
800
+ }
651
801
 
652
802
  // src/lib/client.ts
653
803
  init_proxy();
@@ -665,349 +815,165 @@ function getModel(config) {
665
815
  return config.aiModel ?? DEFAULT_MODEL;
666
816
  }
667
817
 
668
- // src/commands/init.ts
669
- async function getGitHubTokenViaPAT() {
670
- console.log(chalk2.dim("\n Create a token at: https://github.com/settings/tokens/new"));
671
- console.log(chalk2.dim(" Required scopes: repo, read:user\n"));
672
- const token = await password({
673
- message: "GitHub Personal Access Token:",
674
- mask: "*"
675
- });
676
- return { token: token.trim() };
818
+ // src/tools/pick/index.ts
819
+ var pick_exports = {};
820
+ __export(pick_exports, {
821
+ definition: () => definition3,
822
+ execute: () => execute3,
823
+ run: () => run3,
824
+ terminal: () => terminal3
825
+ });
826
+ init_github();
827
+ import chalk5 from "chalk";
828
+ import ora3 from "ora";
829
+ import { select as select3 } from "@inquirer/prompts";
830
+ init_github();
831
+
832
+ // src/lib/markdown.ts
833
+ import { marked } from "marked";
834
+ import { markedTerminal } from "marked-terminal";
835
+ marked.use(markedTerminal({ showSectionPrefix: false }));
836
+ function renderMarkdown(text) {
837
+ return marked(text);
677
838
  }
678
- var OAUTH_CLIENT_ID = "Ov23liW4zJ4r2RdZOsCJ";
679
- async function getGitHubTokenViaDeviceFlow() {
680
- let verificationUri = "";
681
- let userCode = "";
682
- const auth = createOAuthDeviceAuth({
683
- clientType: "oauth-app",
684
- clientId: OAUTH_CLIENT_ID,
685
- scopes: ["repo"],
686
- onVerification(verification) {
687
- verificationUri = verification.verification_uri;
688
- userCode = verification.user_code;
689
- console.log("");
690
- console.log(chalk2.bold(" 1. Open this URL in your browser:"));
691
- console.log(" " + chalk2.cyan(verificationUri));
692
- console.log("");
693
- console.log(chalk2.bold(" 2. Enter this code:"));
694
- console.log(" " + chalk2.yellow.bold(userCode));
695
- console.log("");
696
- open(verificationUri).catch(() => {
697
- });
698
- }
699
- });
700
- const spinner = ora("Waiting for authorization in browser...").start();
701
- let token;
839
+
840
+ // src/lib/display.ts
841
+ init_github();
842
+ import chalk2 from "chalk";
843
+ var LABEL_AVAILABLE2 = "techunter:available";
844
+ var LABEL_CLAIMED2 = "techunter:claimed";
845
+ var LABEL_IN_REVIEW2 = "techunter:in-review";
846
+ var LABEL_CHANGES_NEEDED2 = "techunter:changes-needed";
847
+ function getStatus(issue) {
848
+ if (issue.labels.includes(LABEL_CHANGES_NEEDED2)) return "changes-needed";
849
+ if (issue.labels.includes(LABEL_IN_REVIEW2)) return "in-review";
850
+ if (issue.labels.includes(LABEL_CLAIMED2)) return "claimed";
851
+ if (issue.labels.includes(LABEL_AVAILABLE2)) return "available";
852
+ return "unknown";
853
+ }
854
+ function colorStatus(status) {
855
+ const padded = status.padEnd(14);
856
+ switch (status) {
857
+ case "available":
858
+ return chalk2.green(padded);
859
+ case "claimed":
860
+ return chalk2.yellow(padded);
861
+ case "in-review":
862
+ return chalk2.blue(padded);
863
+ case "changes-needed":
864
+ return chalk2.red(padded);
865
+ default:
866
+ return padded;
867
+ }
868
+ }
869
+ function parentIssueFromBranch(branch) {
870
+ if (!isTaskBranch(branch)) return null;
871
+ const match = branch.match(/^task-(\d+)-/);
872
+ return match ? parseInt(match[1], 10) : null;
873
+ }
874
+ function getParentIssueNumber(issue) {
875
+ const target = extractTargetBranch(issue.body);
876
+ if (!target) return null;
877
+ return parentIssueFromBranch(target);
878
+ }
879
+ function printTaskDetail(issue) {
880
+ const divider = chalk2.dim("\u2500".repeat(70));
881
+ const parentNum = getParentIssueNumber(issue);
882
+ console.log("\n" + divider);
883
+ console.log(
884
+ chalk2.bold(` #${issue.number}`) + " " + colorStatus(getStatus(issue)) + " " + chalk2.dim(issue.assignee ? `@${issue.assignee}` : "\u2014") + (parentNum ? chalk2.dim(` sub-task of #${parentNum}`) : "")
885
+ );
886
+ console.log(chalk2.bold("\n " + issue.title));
887
+ if (issue.body) {
888
+ console.log("");
889
+ console.log(renderMarkdown(issue.body));
890
+ }
891
+ console.log("\n " + chalk2.dim(issue.htmlUrl));
892
+ console.log(divider + "\n");
893
+ }
894
+ async function printTaskList(config) {
702
895
  try {
703
- const result = await auth({ type: "oauth" });
704
- token = result.token;
705
- spinner.succeed("Authorized!");
896
+ const tasks = await listTasks(config);
897
+ const divider = chalk2.dim("\u2500".repeat(70));
898
+ console.log("");
899
+ console.log(chalk2.dim(" " + "#".padEnd(5) + "Status".padEnd(14) + "Assignee".padEnd(16) + "Title"));
900
+ console.log(divider);
901
+ if (tasks.length === 0) {
902
+ console.log(chalk2.dim(" (no tasks)"));
903
+ } else {
904
+ let printTask2 = function(t, indent, connector, isLast) {
905
+ const num = `#${t.number}`.padEnd(5);
906
+ const status = colorStatus(getStatus(t));
907
+ const assignee = (t.assignee ? `@${t.assignee}` : "\u2014").padEnd(16);
908
+ const fullPrefix = indent + connector;
909
+ const maxTitle = 36 - fullPrefix.length;
910
+ const title = t.title.length > maxTitle ? t.title.slice(0, maxTitle - 3) + "..." : t.title;
911
+ console.log(` ${num}${status}${assignee}${chalk2.dim(fullPrefix)}${title}`);
912
+ const children = childrenOf.get(t.number) ?? [];
913
+ const childIndent = indent + (isLast ? " " : "\u2502 ");
914
+ for (let i = 0; i < children.length; i++) {
915
+ const childIsLast = i === children.length - 1;
916
+ printTask2(children[i], childIndent, childIsLast ? "\u2514\u2500 " : "\u251C\u2500 ", childIsLast);
917
+ }
918
+ };
919
+ var printTask = printTask2;
920
+ const taskMap = new Map(tasks.map((t) => [t.number, t]));
921
+ const childrenOf = /* @__PURE__ */ new Map();
922
+ for (const t of tasks) {
923
+ const parentNum = getParentIssueNumber(t);
924
+ const key = parentNum !== null && taskMap.has(parentNum) ? parentNum : null;
925
+ if (!childrenOf.has(key)) childrenOf.set(key, []);
926
+ childrenOf.get(key).push(t);
927
+ }
928
+ const roots = childrenOf.get(null) ?? [];
929
+ for (let i = 0; i < roots.length; i++) {
930
+ const isLast = i === roots.length - 1;
931
+ printTask2(roots[i], "", isLast ? "\u2514\u2500 " : "\u251C\u2500 ", isLast);
932
+ }
933
+ }
934
+ console.log(divider);
935
+ return tasks;
706
936
  } catch (err) {
707
- spinner.fail("Authorization failed");
708
- throw err;
937
+ console.log(chalk2.yellow(`(Could not load tasks: ${err.message})`));
938
+ return [];
709
939
  }
710
- return { token, clientId: OAUTH_CLIENT_ID };
711
940
  }
712
- async function initCommand() {
713
- console.log(chalk2.bold.cyan("\nTechunter \u2014 Initial Setup\n"));
714
- let detectedOwner = "";
715
- let detectedRepo = "";
716
- const remoteUrl = await getRemoteUrl();
717
- if (remoteUrl) {
718
- const parsed = parseOwnerRepo(remoteUrl);
719
- if (parsed) {
720
- detectedOwner = parsed.owner;
721
- detectedRepo = parsed.repo;
722
- console.log(chalk2.dim(`Detected GitHub repo: ${detectedOwner}/${detectedRepo}
723
- `));
941
+ async function printMyTasks(config) {
942
+ try {
943
+ const me = await getAuthenticatedUser(config);
944
+ const tasks = await listMyTasks(config, me);
945
+ if (tasks.length === 0) return;
946
+ const divider = chalk2.dim("\u2500".repeat(70));
947
+ console.log("");
948
+ console.log(chalk2.dim(" " + "#".padEnd(5) + "Status".padEnd(14) + `My Tasks @${me}`));
949
+ console.log(divider);
950
+ for (const t of tasks) {
951
+ const num = `#${t.number}`.padEnd(5);
952
+ const status = colorStatus(getStatus(t));
953
+ const parentNum = getParentIssueNumber(t);
954
+ const parentTag = parentNum ? chalk2.dim(` (sub of #${parentNum})`) : "";
955
+ const maxTitle = parentNum ? 34 : 46;
956
+ const title = t.title.length > maxTitle ? t.title.slice(0, maxTitle - 3) + "..." : t.title;
957
+ console.log(` ${num}${status}${title}${parentTag}`);
724
958
  }
725
- }
726
- const authMethod = await select({
727
- message: "How would you like to authenticate with GitHub?",
728
- choices: [
729
- {
730
- name: "Browser login (OAuth) \u2014 open a URL and click Authorize",
731
- value: "device"
732
- },
733
- {
734
- name: "Personal Access Token (PAT) \u2014 paste a token from github.com/settings/tokens",
735
- value: "pat"
736
- }
737
- ]
738
- });
739
- let githubToken;
740
- let githubClientId;
741
- if (authMethod === "device") {
742
- const result = await getGitHubTokenViaDeviceFlow();
743
- githubToken = result.token;
744
- githubClientId = result.clientId;
745
- } else {
746
- const result = await getGitHubTokenViaPAT();
747
- githubToken = result.token;
748
- }
749
- const providerChoice = await select({
750
- message: "AI provider:",
751
- choices: [
752
- { name: `OpenRouter (recommended) ${chalk2.dim(`${DEFAULT_BASE_URL} \xB7 ${DEFAULT_MODEL}`)}`, value: "openrouter" },
753
- { name: "Custom (specify base URL and model)", value: "custom" }
754
- ]
755
- });
756
- let aiBaseUrl;
757
- let aiModel;
758
- if (providerChoice === "custom") {
759
- aiBaseUrl = (await input({ message: "API base URL:", default: DEFAULT_BASE_URL })).trim();
760
- aiModel = (await input({ message: "Model name:", default: DEFAULT_MODEL })).trim();
761
- }
762
- const apiKeyHint = providerChoice === "openrouter" ? chalk2.dim(" Get a key at: https://openrouter.ai/settings/keys\n") : chalk2.dim(" API key for your provider\n");
763
- console.log(apiKeyHint);
764
- const aiApiKey = await password({
765
- message: "API Key:",
766
- mask: "*"
767
- });
768
- let owner = detectedOwner;
769
- let repo = detectedRepo;
770
- if (!owner || !repo) {
771
- owner = await input({
772
- message: "GitHub repo owner (user or org):",
773
- required: true
774
- });
775
- repo = await input({
776
- message: "GitHub repo name:",
777
- required: true
778
- });
779
- }
780
- const config = {
781
- githubToken,
782
- githubClientId,
783
- aiApiKey: aiApiKey.trim(),
784
- ...aiBaseUrl ? { aiBaseUrl } : {},
785
- ...aiModel ? { aiModel } : {},
786
- github: {
787
- owner: owner.trim(),
788
- repo: repo.trim()
789
- }
790
- };
791
- setConfig(config);
792
- const spinner = ora("Setting up GitHub labels...").start();
793
- try {
794
- await ensureLabels(config);
795
- spinner.succeed("GitHub labels created");
796
- } catch (err) {
797
- spinner.fail("Failed to create labels (check token permissions)");
798
- console.error(chalk2.red(String(err)));
799
- }
800
- console.log(chalk2.green("\nSetup complete!"));
801
- console.log(chalk2.dim(`Config saved to: ${getConfigPath()}
802
- `));
803
- }
804
-
805
- // src/commands/config.ts
806
- import { input as input2, password as password2, select as select2 } from "@inquirer/prompts";
807
- import chalk3 from "chalk";
808
- async function configCommand() {
809
- let config;
810
- try {
811
- config = getConfig();
812
- } catch {
813
- console.error(chalk3.red("No config found. Run `tch init` first."));
814
- process.exit(1);
815
- }
816
- console.log(chalk3.bold.cyan("\nTechunter \u2014 Settings\n"));
817
- console.log(chalk3.dim(`Config file: ${getConfigPath()}
818
- `));
819
- const currentBaseUrl = config.aiBaseUrl ?? DEFAULT_BASE_URL;
820
- const currentModel = config.aiModel ?? DEFAULT_MODEL;
821
- const currentBaseBranch = config.baseBranch ?? "main";
822
- const field = await select2({
823
- message: "Which setting to change?",
824
- choices: [
825
- { name: `GitHub repo ${chalk3.dim(`${config.github.owner}/${config.github.repo}`)}`, value: "repo" },
826
- { name: `Base branch ${chalk3.dim(currentBaseBranch)}`, value: "baseBranch" },
827
- { name: `AI base URL ${chalk3.dim(currentBaseUrl)}`, value: "aiBaseUrl" },
828
- { name: `AI model ${chalk3.dim(currentModel)}`, value: "aiModel" },
829
- { name: `AI API Key ${chalk3.dim("(hidden)")}`, value: "aiApiKey" },
830
- { name: `GitHub Token ${chalk3.dim("(hidden)")}`, value: "githubToken" },
831
- { name: "Cancel", value: "cancel" }
832
- ]
833
- });
834
- if (field === "cancel") return;
835
- if (field === "baseBranch") {
836
- const val = await input2({ message: "Base branch name:", default: currentBaseBranch });
837
- if (val.trim()) {
838
- setConfig({ baseBranch: val.trim() });
839
- console.log(chalk3.green(`
840
- Base branch set to: ${val.trim()}
841
- `));
842
- }
843
- } else if (field === "repo") {
844
- const owner = await input2({ message: "GitHub repo owner:", default: config.github.owner });
845
- const repo = await input2({ message: "GitHub repo name:", default: config.github.repo });
846
- setConfig({ github: { ...config.github, owner: owner.trim(), repo: repo.trim() } });
847
- console.log(chalk3.green(`
848
- Repo set to: ${owner.trim()}/${repo.trim()}
849
- `));
850
- } else if (field === "aiBaseUrl") {
851
- const val = await input2({ message: "AI base URL:", default: currentBaseUrl });
852
- if (val.trim()) {
853
- setConfig({ aiBaseUrl: val.trim() });
854
- console.log(chalk3.green(`
855
- AI base URL set to: ${val.trim()}
856
- `));
857
- }
858
- } else if (field === "aiModel") {
859
- const val = await input2({ message: "AI model name:", default: currentModel });
860
- if (val.trim()) {
861
- setConfig({ aiModel: val.trim() });
862
- console.log(chalk3.green(`
863
- AI model set to: ${val.trim()}
864
- `));
865
- }
866
- } else if (field === "aiApiKey") {
867
- const val = await password2({ message: "New AI API Key:", mask: "*" });
868
- if (val.trim()) {
869
- setConfig({ aiApiKey: val.trim() });
870
- console.log(chalk3.green("\nAI API Key updated.\n"));
871
- }
872
- } else if (field === "githubToken") {
873
- const val = await password2({ message: "New GitHub Token:", mask: "*" });
874
- if (val.trim()) {
875
- setConfig({ githubToken: val.trim() });
876
- console.log(chalk3.green("\nGitHub Token updated.\n"));
877
- }
878
- }
879
- }
880
-
881
- // src/index.ts
882
- init_github();
883
-
884
- // src/lib/agent.ts
885
- import ora15 from "ora";
886
- import chalk13 from "chalk";
887
-
888
- // src/tools/pick/index.ts
889
- var pick_exports = {};
890
- __export(pick_exports, {
891
- definition: () => definition3,
892
- execute: () => execute3,
893
- run: () => run3,
894
- terminal: () => terminal3
895
- });
896
- init_github();
897
- import chalk8 from "chalk";
898
- import ora4 from "ora";
899
- import { select as select5 } from "@inquirer/prompts";
900
- init_github();
901
-
902
- // src/lib/markdown.ts
903
- import { marked } from "marked";
904
- import { markedTerminal } from "marked-terminal";
905
- marked.use(markedTerminal({ showSectionPrefix: false }));
906
- function renderMarkdown(text) {
907
- return marked(text);
908
- }
909
-
910
- // src/lib/display.ts
911
- init_github();
912
- import chalk4 from "chalk";
913
- var LABEL_AVAILABLE2 = "techunter:available";
914
- var LABEL_CLAIMED2 = "techunter:claimed";
915
- var LABEL_IN_REVIEW2 = "techunter:in-review";
916
- var LABEL_CHANGES_NEEDED2 = "techunter:changes-needed";
917
- function getStatus(issue) {
918
- if (issue.labels.includes(LABEL_CHANGES_NEEDED2)) return "changes-needed";
919
- if (issue.labels.includes(LABEL_IN_REVIEW2)) return "in-review";
920
- if (issue.labels.includes(LABEL_CLAIMED2)) return "claimed";
921
- if (issue.labels.includes(LABEL_AVAILABLE2)) return "available";
922
- return "unknown";
923
- }
924
- function colorStatus(status) {
925
- const padded = status.padEnd(14);
926
- switch (status) {
927
- case "available":
928
- return chalk4.green(padded);
929
- case "claimed":
930
- return chalk4.yellow(padded);
931
- case "in-review":
932
- return chalk4.blue(padded);
933
- case "changes-needed":
934
- return chalk4.red(padded);
935
- default:
936
- return padded;
937
- }
938
- }
939
- function printTaskDetail(issue) {
940
- const divider = chalk4.dim("\u2500".repeat(70));
941
- console.log("\n" + divider);
942
- console.log(
943
- chalk4.bold(` #${issue.number}`) + " " + colorStatus(getStatus(issue)) + " " + chalk4.dim(issue.assignee ? `@${issue.assignee}` : "\u2014")
944
- );
945
- console.log(chalk4.bold("\n " + issue.title));
946
- if (issue.body) {
947
- console.log("");
948
- console.log(renderMarkdown(issue.body));
949
- }
950
- console.log("\n " + chalk4.dim(issue.htmlUrl));
951
- console.log(divider + "\n");
952
- }
953
- async function printTaskList(config) {
954
- try {
955
- const tasks = await listTasks(config);
956
- const divider = chalk4.dim("\u2500".repeat(70));
957
- console.log("");
958
- console.log(chalk4.dim(" " + "#".padEnd(5) + "Status".padEnd(14) + "Assignee".padEnd(16) + "Title"));
959
- console.log(divider);
960
- if (tasks.length === 0) {
961
- console.log(chalk4.dim(" (no tasks)"));
962
- } else {
963
- for (const t of tasks) {
964
- const num = `#${t.number}`.padEnd(5);
965
- const status = colorStatus(getStatus(t));
966
- const assignee = (t.assignee ? `@${t.assignee}` : "\u2014").padEnd(16);
967
- const title = t.title.length > 36 ? t.title.slice(0, 33) + "..." : t.title;
968
- console.log(` ${num}${status}${assignee}${title}`);
969
- }
970
- }
971
- console.log(divider);
972
- return tasks;
973
- } catch (err) {
974
- console.log(chalk4.yellow(`(Could not load tasks: ${err.message})`));
975
- return [];
976
- }
977
- }
978
- async function printMyTasks(config) {
979
- try {
980
- const me = await getAuthenticatedUser(config);
981
- const tasks = await listMyTasks(config, me);
982
- if (tasks.length === 0) return;
983
- const divider = chalk4.dim("\u2500".repeat(70));
984
- console.log("");
985
- console.log(chalk4.dim(" " + "#".padEnd(5) + "Status".padEnd(14) + `My Tasks @${me}`));
986
- console.log(divider);
987
- for (const t of tasks) {
988
- const num = `#${t.number}`.padEnd(5);
989
- const status = colorStatus(getStatus(t));
990
- const title = t.title.length > 46 ? t.title.slice(0, 43) + "..." : t.title;
991
- console.log(` ${num}${status}${title}`);
992
- }
993
- console.log(divider);
994
- const rejectedTasks = tasks.filter((t) => t.labels.includes(LABEL_CHANGES_NEEDED2));
995
- if (rejectedTasks.length > 0) {
996
- let currentBranch = "";
997
- try {
998
- currentBranch = await getCurrentBranch();
999
- } catch {
959
+ console.log(divider);
960
+ const rejectedTasks = tasks.filter((t) => t.labels.includes(LABEL_CHANGES_NEEDED2));
961
+ if (rejectedTasks.length > 0) {
962
+ let currentBranch = "";
963
+ try {
964
+ currentBranch = await getCurrentBranch();
965
+ } catch {
1000
966
  }
1001
967
  console.log("");
1002
968
  for (const t of rejectedTasks) {
1003
- const expectedBranch = makeBranchName(t.number, t.title);
1004
- const onCorrectBranch = currentBranch === expectedBranch;
969
+ const taskBranch = t.assignee ? makeTaskBranchName(t.number, t.assignee) : `task-${t.number}`;
970
+ const onCorrectBranch = currentBranch === taskBranch;
1005
971
  console.log(
1006
- chalk4.red.bold(" \u26A0 Changes requested") + chalk4.red(` on #${t.number} "${t.title}"`)
972
+ chalk2.red.bold(" \u26A0 Changes requested") + chalk2.red(` on #${t.number} "${t.title}"`)
1007
973
  );
1008
974
  if (!onCorrectBranch) {
1009
975
  console.log(
1010
- chalk4.dim(" Switch branch: ") + chalk4.cyan(`git checkout ${expectedBranch}`)
976
+ chalk2.dim(" Switch branch: ") + chalk2.cyan(`git checkout ${taskBranch}`)
1011
977
  );
1012
978
  }
1013
979
  }
@@ -1019,7 +985,7 @@ async function printMyTasks(config) {
1019
985
 
1020
986
  // src/lib/launch.ts
1021
987
  import { spawn } from "child_process";
1022
- import chalk5 from "chalk";
988
+ import chalk3 from "chalk";
1023
989
  function buildClaudePrompt(issue, branch) {
1024
990
  const lines = [
1025
991
  `You are working on task #${issue.number}: ${issue.title}`,
@@ -1035,14 +1001,14 @@ function buildClaudePrompt(issue, branch) {
1035
1001
  }
1036
1002
  async function launchClaudeCode(issue, branch) {
1037
1003
  const prompt = buildClaudePrompt(issue, branch);
1038
- console.log(chalk5.dim("\n Launching Claude Code\u2026\n"));
1004
+ console.log(chalk3.dim("\n Launching Claude Code\u2026\n"));
1039
1005
  await new Promise((resolve) => {
1040
1006
  const safePrompt = prompt.replace(/\r?\n/g, " ").replace(/"/g, "'");
1041
1007
  const child = spawn(`claude "${safePrompt}"`, [], { stdio: "inherit", shell: true });
1042
1008
  child.on("close", () => resolve());
1043
1009
  child.on("error", () => {
1044
1010
  console.log(
1045
- chalk5.yellow(
1011
+ chalk3.yellow(
1046
1012
  " Could not launch claude. Make sure Claude Code is installed:\n npm install -g @anthropic-ai/claude-code"
1047
1013
  )
1048
1014
  );
@@ -1060,81 +1026,12 @@ __export(submit_exports, {
1060
1026
  terminal: () => terminal
1061
1027
  });
1062
1028
  init_github();
1063
- import chalk7 from "chalk";
1064
- import ora2 from "ora";
1065
- import { select as select3, input as promptInput } from "@inquirer/prompts";
1029
+ import chalk4 from "chalk";
1030
+ import ora from "ora";
1031
+ import { select, input as promptInput } from "@inquirer/prompts";
1066
1032
 
1067
- // src/lib/agent-ui.ts
1068
- import chalk6 from "chalk";
1069
- function formatInput(input3) {
1070
- return Object.entries(input3).map(([k, v]) => {
1071
- if (typeof v === "number") return `${k}=${v}`;
1072
- if (typeof v === "string") {
1073
- if (k === "body" || v.length > 50) return `${k}=[${v.length} chars]`;
1074
- return `${k}="${v}"`;
1075
- }
1076
- return `${k}=${JSON.stringify(v)}`;
1077
- }).join(" ");
1078
- }
1079
- function summarize(result) {
1080
- const first = result.split("\n").find((l) => l.trim()) ?? result;
1081
- return first.length > 100 ? first.slice(0, 97) + "..." : first;
1082
- }
1083
- function printToolCall(name, input3) {
1084
- const params = formatInput(input3);
1085
- console.log(` ${chalk6.cyan("\u2192")} ${chalk6.bold(name)}${params ? " " + chalk6.dim(params) : ""}`);
1086
- }
1087
- function printToolResult(result) {
1088
- const ok = !result.startsWith("Error:");
1089
- const icon = ok ? chalk6.green("\u2713") : chalk6.red("\u2717");
1090
- console.log(` ${icon} ${chalk6.dim(summarize(result))}`);
1091
- }
1092
-
1093
- // src/lib/sub-agent.ts
1094
- async function runSubAgentLoop(config, systemPrompt, userMessage, toolNames) {
1095
- const client = createClient(config);
1096
- const selected = toolModules.filter((m) => toolNames.includes(m.definition.function.name));
1097
- const tools2 = selected.map((m) => m.definition);
1098
- const messages = [
1099
- { role: "system", content: systemPrompt },
1100
- { role: "user", content: userMessage }
1101
- ];
1102
- const MAX_ITERATIONS = 100;
1103
- let iterations = 0;
1104
- for (; ; ) {
1105
- if (++iterations > MAX_ITERATIONS) {
1106
- throw new Error(`Sub-agent exceeded ${MAX_ITERATIONS} iterations without finishing.`);
1107
- }
1108
- const res = await client.chat.completions.create({ model: getModel(config), tools: tools2, messages });
1109
- const choice = res.choices[0];
1110
- messages.push({
1111
- role: "assistant",
1112
- content: choice.message.content ?? null,
1113
- ...choice.message.tool_calls ? { tool_calls: choice.message.tool_calls } : {}
1114
- });
1115
- if (choice.finish_reason === "stop") {
1116
- return choice.message.content ?? "";
1117
- }
1118
- if (choice.finish_reason === "tool_calls") {
1119
- for (const tc of choice.message.tool_calls ?? []) {
1120
- let input3;
1121
- try {
1122
- input3 = JSON.parse(tc.function.arguments);
1123
- } catch {
1124
- input3 = {};
1125
- }
1126
- printToolCall(tc.function.name, input3);
1127
- const mod = selected.find((m) => m.definition.function.name === tc.function.name);
1128
- const result = mod ? await mod.execute(input3, config) : `Unknown tool: ${tc.function.name}`;
1129
- printToolResult(result);
1130
- messages.push({ role: "tool", tool_call_id: tc.id, content: result });
1131
- }
1132
- }
1133
- }
1134
- }
1135
-
1136
- // src/tools/submit/prompts.ts
1137
- 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.";
1033
+ // src/tools/submit/prompts.ts
1034
+ 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.";
1138
1035
 
1139
1036
  // src/tools/submit/reviewer.ts
1140
1037
  async function reviewChanges(config, issueNumber, issue, diff) {
@@ -1148,7 +1045,7 @@ ${issue.body ?? "(none)"}
1148
1045
 
1149
1046
  Diff:
1150
1047
  ${diff || "(no changes)"}`,
1151
- ["run_command", "read_file", "get_diff"]
1048
+ ["run_command", "grep_code", "get_diff"]
1152
1049
  );
1153
1050
  }
1154
1051
 
@@ -1169,11 +1066,21 @@ var definition = {
1169
1066
  };
1170
1067
  async function run(_input, config) {
1171
1068
  const taskState = getConfig().taskState;
1172
- const issueNumber = taskState?.activeIssueNumber;
1069
+ const currentBranch = await getCurrentBranch();
1070
+ let issueNumber = taskState?.activeIssueNumber && taskState?.activeBranch && currentBranch === taskState.activeBranch ? taskState.activeIssueNumber : void 0;
1173
1071
  if (!issueNumber) {
1174
- return "No active task found. Claim a task first with /pick.";
1072
+ const fromBranch = parseIssueNumberFromBranch(currentBranch);
1073
+ if (fromBranch) {
1074
+ issueNumber = fromBranch;
1075
+ } else {
1076
+ const found = await getIssueNumberFromBranch(config, currentBranch);
1077
+ if (!found) {
1078
+ return "No active task found. Claim a task first with /pick.";
1079
+ }
1080
+ issueNumber = found.issueNumber;
1081
+ }
1175
1082
  }
1176
- let spinner = ora2("Loading task and diff\u2026").start();
1083
+ let spinner = ora("Loading task and diff\u2026").start();
1177
1084
  const diffPromise = taskState?.baseCommit ? getDiffFromCommit(taskState.baseCommit) : getDiff();
1178
1085
  const [issue, diff, me] = await Promise.all([
1179
1086
  getTask(config, issueNumber),
@@ -1181,12 +1088,19 @@ async function run(_input, config) {
1181
1088
  getAuthenticatedUser(config)
1182
1089
  ]);
1183
1090
  spinner.stop();
1184
- const workerBranch = makeWorkerBranchName(issue.author ?? me);
1091
+ const targetBranch = extractTargetBranch(issue.body) ?? makeWorkerBranchName(issue.author ?? me);
1185
1092
  const branch = await getCurrentBranch();
1186
1093
  const isSelfSubmit = issue.author !== null && issue.author === me;
1094
+ spinner = ora("Checking for open sub-tasks\u2026").start();
1095
+ const openSubtaskNumbers = await getOpenSubtasks(config, branch);
1096
+ spinner.stop();
1097
+ if (openSubtaskNumbers.length > 0) {
1098
+ return `Cannot submit: ${openSubtaskNumbers.length} sub-task(s) still open:
1099
+ ` + openSubtaskNumbers.map((n) => ` - #${n}`).join("\n") + "\nComplete all sub-tasks before submitting.";
1100
+ }
1187
1101
  let review = "";
1188
1102
  if (!isSelfSubmit) {
1189
- const reviewSpinner = ora2("Reviewing changes\u2026").start();
1103
+ const reviewSpinner = ora("Reviewing changes\u2026").start();
1190
1104
  try {
1191
1105
  review = await reviewChanges(config, issueNumber, issue, diff);
1192
1106
  } catch (err) {
@@ -1194,19 +1108,19 @@ async function run(_input, config) {
1194
1108
  }
1195
1109
  reviewSpinner.stop();
1196
1110
  }
1197
- const divider = chalk7.dim("\u2500".repeat(70));
1111
+ const divider = chalk4.dim("\u2500".repeat(70));
1198
1112
  console.log("\n" + divider);
1199
1113
  if (isSelfSubmit) {
1200
- console.log(chalk7.yellow(` Self-submit detected \u2014 AI review skipped.`));
1114
+ console.log(chalk4.yellow(` Self-submit detected \u2014 AI review skipped.`));
1201
1115
  } else {
1202
- console.log(chalk7.bold(` Review \u2014 task #${issueNumber} "${issue.title}"`));
1116
+ console.log(chalk4.bold(` Review \u2014 task #${issueNumber} "${issue.title}"`));
1203
1117
  console.log(divider);
1204
1118
  console.log(renderMarkdown(review));
1205
1119
  }
1206
1120
  console.log(divider + "\n");
1207
1121
  let shouldProceed;
1208
1122
  try {
1209
- shouldProceed = await select3({
1123
+ shouldProceed = await select({
1210
1124
  message: `Submit task #${issueNumber}?`,
1211
1125
  choices: [
1212
1126
  { name: "Yes, submit", value: true },
@@ -1227,7 +1141,7 @@ async function run(_input, config) {
1227
1141
  return "Submit cancelled.";
1228
1142
  }
1229
1143
  if (!commitMessage.trim()) return "Submit cancelled.";
1230
- spinner = ora2("Committing and pushing\u2026").start();
1144
+ spinner = ora("Committing and pushing\u2026").start();
1231
1145
  try {
1232
1146
  await stageAllAndCommit(commitMessage.trim());
1233
1147
  spinner.stop();
@@ -1236,52 +1150,71 @@ async function run(_input, config) {
1236
1150
  return `Commit failed: ${err.message}`;
1237
1151
  }
1238
1152
  if (isSelfSubmit) {
1239
- spinner = ora2("Closing issue\u2026").start();
1153
+ spinner = ora("Closing issue\u2026").start();
1240
1154
  try {
1241
1155
  await closeTask(config, issueNumber);
1242
1156
  spinner.stop();
1243
1157
  } catch (err) {
1244
1158
  spinner.stop();
1245
- console.error(chalk7.yellow(`Warning: failed to close issue: ${err.message}`));
1159
+ console.error(chalk4.yellow(`Warning: failed to close issue: ${err.message}`));
1246
1160
  }
1247
- setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
1161
+ setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0, activeBranch: void 0 } });
1248
1162
  return `Task #${issueNumber} committed and closed.
1249
1163
  Commit: "${commitMessage.trim()}"`;
1250
1164
  }
1251
- spinner = ora2("Creating pull request\u2026").start();
1165
+ spinner = ora("Checking for existing PR\u2026").start();
1166
+ const existingPR = await getTaskPR(config, issueNumber);
1167
+ spinner.stop();
1252
1168
  let prUrl;
1253
- try {
1254
- const prBody = [
1255
- `Closes #${issueNumber}`,
1256
- issue.body ? `
1169
+ if (existingPR) {
1170
+ prUrl = existingPR.url;
1171
+ console.log(chalk4.dim(` Existing PR found: ${prUrl} \u2014 updating.`));
1172
+ } else {
1173
+ spinner = ora("Creating pull request\u2026").start();
1174
+ try {
1175
+ await ensureRemoteBranch(config, targetBranch, config.baseBranch ?? "main");
1176
+ const prBody = [
1177
+ `Closes #${issueNumber}`,
1178
+ issue.body ? `
1257
1179
  ${issue.body}` : "",
1258
- review ? `
1180
+ review ? `
1259
1181
  ## AI Review
1260
1182
  ${review}` : ""
1261
- ].join("\n").trim();
1262
- prUrl = await createPR(config, issue.title, prBody, branch, workerBranch);
1263
- spinner.stop();
1264
- } catch (err) {
1265
- spinner.stop();
1266
- return `Committed but PR creation failed: ${err.message}`;
1183
+ ].join("\n").trim();
1184
+ prUrl = await createPR(config, issue.title, prBody, branch, targetBranch);
1185
+ spinner.stop();
1186
+ } catch (err) {
1187
+ spinner.stop();
1188
+ return `Committed but PR creation failed: ${err.message}`;
1189
+ }
1267
1190
  }
1268
- spinner = ora2("Marking as in-review\u2026").start();
1191
+ spinner = ora("Marking as in-review\u2026").start();
1269
1192
  try {
1270
1193
  await markInReview(config, issueNumber);
1271
1194
  spinner.stop();
1272
1195
  } catch (err) {
1273
1196
  spinner.stop();
1274
- return `PR created (${prUrl}) but failed to update label: ${err.message}`;
1197
+ return `PR ${existingPR ? "updated" : "created"} (${prUrl}) but failed to update label: ${err.message}`;
1275
1198
  }
1276
- setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
1277
- return `Task #${issueNumber} submitted.
1199
+ setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0, activeBranch: void 0 } });
1200
+ return `Task #${issueNumber} ${existingPR ? "re-submitted" : "submitted"}.
1278
1201
  Commit: "${commitMessage.trim()}"
1279
1202
  PR: ${prUrl}`;
1280
1203
  }
1281
1204
  async function execute(input3, config) {
1282
1205
  const taskState = getConfig().taskState;
1283
- const issueNumber = taskState?.activeIssueNumber;
1284
- if (!issueNumber) return "No active task found. Claim a task first.";
1206
+ let issueNumber = taskState?.activeIssueNumber;
1207
+ if (!issueNumber) {
1208
+ const currentBranch = await getCurrentBranch();
1209
+ const fromBranch = parseIssueNumberFromBranch(currentBranch);
1210
+ if (fromBranch) {
1211
+ issueNumber = fromBranch;
1212
+ } else {
1213
+ const found = await getIssueNumberFromBranch(config, currentBranch);
1214
+ if (!found) return "No active task found. Claim a task first.";
1215
+ issueNumber = found.issueNumber;
1216
+ }
1217
+ }
1285
1218
  const diffPromise = taskState?.baseCommit ? getDiffFromCommit(taskState.baseCommit) : getDiff();
1286
1219
  const [issue, diff, branch, me] = await Promise.all([
1287
1220
  getTask(config, issueNumber),
@@ -1289,7 +1222,11 @@ async function execute(input3, config) {
1289
1222
  getCurrentBranch(),
1290
1223
  getAuthenticatedUser(config)
1291
1224
  ]);
1292
- const workerBranch = makeWorkerBranchName(issue.author ?? me);
1225
+ const targetBranch = extractTargetBranch(issue.body) ?? makeWorkerBranchName(issue.author ?? me);
1226
+ const openSubtaskNumbers = await getOpenSubtasks(config, branch);
1227
+ if (openSubtaskNumbers.length > 0) {
1228
+ return `Cannot submit: ${openSubtaskNumbers.length} sub-task(s) still open: ` + openSubtaskNumbers.map((n) => `#${n}`).join(", ");
1229
+ }
1293
1230
  const isSelfSubmit = issue.author !== null && issue.author === me;
1294
1231
  let review = "";
1295
1232
  if (!isSelfSubmit) {
@@ -1310,29 +1247,36 @@ async function execute(input3, config) {
1310
1247
  await closeTask(config, issueNumber);
1311
1248
  } catch {
1312
1249
  }
1313
- setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0 } });
1250
+ setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0, activeBranch: void 0 } });
1314
1251
  return `Task #${issueNumber} committed and closed.
1315
1252
  Commit: "${commitMessage}"`;
1316
1253
  }
1254
+ const existingPR = await getTaskPR(config, issueNumber);
1317
1255
  let prUrl;
1318
- try {
1319
- const prBody = [
1320
- `Closes #${issueNumber}`,
1321
- issue.body ? `
1256
+ if (existingPR) {
1257
+ prUrl = existingPR.url;
1258
+ } else {
1259
+ try {
1260
+ await ensureRemoteBranch(config, targetBranch, config.baseBranch ?? "main");
1261
+ const prBody = [
1262
+ `Closes #${issueNumber}`,
1263
+ issue.body ? `
1322
1264
  ${issue.body}` : "",
1323
- review ? `
1265
+ review ? `
1324
1266
  ## AI Review
1325
1267
  ${review}` : ""
1326
- ].join("\n").trim();
1327
- prUrl = await createPR(config, issue.title, prBody, branch, workerBranch);
1328
- } catch (err) {
1329
- return `Committed but PR creation failed: ${err.message}`;
1268
+ ].join("\n").trim();
1269
+ prUrl = await createPR(config, issue.title, prBody, branch, targetBranch);
1270
+ } catch (err) {
1271
+ return `Committed but PR creation failed: ${err.message}`;
1272
+ }
1330
1273
  }
1331
1274
  try {
1332
1275
  await markInReview(config, issueNumber);
1333
1276
  } catch {
1334
1277
  }
1335
- return `Task #${issueNumber} submitted.
1278
+ setConfig({ taskState: { activeIssueNumber: void 0, baseCommit: void 0, activeBranch: void 0 } });
1279
+ return `Task #${issueNumber} ${existingPR ? "re-submitted" : "submitted"}.
1336
1280
  Review:
1337
1281
  ${review}
1338
1282
  Commit: "${commitMessage}"
@@ -1349,8 +1293,8 @@ __export(close_exports, {
1349
1293
  terminal: () => terminal2
1350
1294
  });
1351
1295
  init_github();
1352
- import { select as select4 } from "@inquirer/prompts";
1353
- import ora3 from "ora";
1296
+ import { select as select2 } from "@inquirer/prompts";
1297
+ import ora2 from "ora";
1354
1298
  var definition2 = {
1355
1299
  type: "function",
1356
1300
  function: {
@@ -1376,7 +1320,7 @@ async function run2(input3, config) {
1376
1320
  }
1377
1321
  if (tasks.length === 0) return "No tasks found.";
1378
1322
  try {
1379
- issueNumber = await select4({
1323
+ issueNumber = await select2({
1380
1324
  message: "Select task to close:",
1381
1325
  choices: tasks.map((t) => ({ name: `#${t.number} [${getStatus(t)}] ${t.title}`, value: t.number }))
1382
1326
  });
@@ -1386,7 +1330,7 @@ async function run2(input3, config) {
1386
1330
  }
1387
1331
  let confirmed;
1388
1332
  try {
1389
- confirmed = await select4({
1333
+ confirmed = await select2({
1390
1334
  message: `Close task #${issueNumber}?`,
1391
1335
  choices: [
1392
1336
  { name: "Yes, close it", value: true },
@@ -1397,7 +1341,7 @@ async function run2(input3, config) {
1397
1341
  return "Cancelled.";
1398
1342
  }
1399
1343
  if (!confirmed) return "Cancelled.";
1400
- const spinner = ora3(`Closing #${issueNumber}\u2026`).start();
1344
+ const spinner = ora2(`Closing #${issueNumber}\u2026`).start();
1401
1345
  try {
1402
1346
  await closeTask(config, issueNumber);
1403
1347
  spinner.stop();
@@ -1409,7 +1353,7 @@ async function run2(input3, config) {
1409
1353
  }
1410
1354
  async function execute2(input3, config) {
1411
1355
  const issueNumber = input3["issue_number"];
1412
- const spinner = ora3(`Closing #${issueNumber}\u2026`).start();
1356
+ const spinner = ora2(`Closing #${issueNumber}\u2026`).start();
1413
1357
  try {
1414
1358
  await closeTask(config, issueNumber);
1415
1359
  spinner.stop();
@@ -1455,7 +1399,7 @@ async function run3(input3, config) {
1455
1399
  }
1456
1400
  if (tasks.length === 0) return "No tasks found.";
1457
1401
  try {
1458
- chosenNumber = await select5({
1402
+ chosenNumber = await select3({
1459
1403
  message: "Select a task:",
1460
1404
  choices: tasks.map((t) => ({
1461
1405
  name: `#${String(t.number).padEnd(4)} ${colorStatus(getStatus(t))} ${t.title}`,
@@ -1479,9 +1423,9 @@ async function run3(input3, config) {
1479
1423
  const comments = await listComments(config, issue.number, 1);
1480
1424
  if (comments.length > 0) {
1481
1425
  const c = comments[0];
1482
- const divider = chalk8.dim("\u2500".repeat(70));
1426
+ const divider = chalk5.dim("\u2500".repeat(70));
1483
1427
  console.log(
1484
- chalk8.red.bold(" Latest rejection feedback") + chalk8.dim(` \u2014 @${c.author} \xB7 ${c.createdAt.slice(0, 10)}`)
1428
+ chalk5.red.bold(" Latest rejection feedback") + chalk5.dim(` \u2014 @${c.author} \xB7 ${c.createdAt.slice(0, 10)}`)
1485
1429
  );
1486
1430
  console.log(divider);
1487
1431
  console.log(renderMarkdown(c.body));
@@ -1491,22 +1435,36 @@ async function run3(input3, config) {
1491
1435
  }
1492
1436
  }
1493
1437
  const actions = [];
1494
- if (status === "available") actions.push({ name: "Claim this task", value: "claim" });
1495
- if (status === "claimed" || status === "changes-needed") actions.push({ name: "Submit this task", value: "submit" });
1438
+ if (status === "available") {
1439
+ actions.push({ name: "Claim this task", value: "claim" });
1440
+ }
1441
+ if (status === "claimed") {
1442
+ actions.push({ name: "Submit this task", value: "submit" });
1443
+ }
1444
+ if (status === "changes-needed") {
1445
+ const { getAuthenticatedUser: getAuthenticatedUser2 } = await Promise.resolve().then(() => (init_github(), github_exports));
1446
+ const me = await getAuthenticatedUser2(config);
1447
+ const taskBranch = issue.assignee ? makeTaskBranchName(issue.number, issue.assignee) : makeTaskBranchName(issue.number, me);
1448
+ const currentBranch = await getCurrentBranch();
1449
+ if (currentBranch === taskBranch) {
1450
+ actions.push({ name: "Submit this task (fixes done)", value: "submit" });
1451
+ } else {
1452
+ actions.push({ name: `Switch to ${taskBranch} to fix`, value: "switch-fix" });
1453
+ }
1454
+ }
1496
1455
  actions.push({ name: "Close this task", value: "close" });
1497
1456
  actions.push({ name: "Nothing, just viewing", value: "none" });
1498
1457
  let action;
1499
1458
  try {
1500
- action = await select5({ message: "Action:", choices: actions });
1459
+ action = await select3({ message: "Action:", choices: actions });
1501
1460
  } catch {
1502
1461
  return "Cancelled.";
1503
1462
  }
1504
1463
  if (action === "none") return `Viewed task #${issue.number}.`;
1505
1464
  if (action === "claim") {
1506
1465
  try {
1507
- const { getAuthenticatedUser: getAuthenticatedUser2, listMyTasks: listMyTasks2 } = await Promise.resolve().then(() => (init_github(), github_exports));
1508
- const me = await getAuthenticatedUser2(config);
1509
- const myTasks = await listMyTasks2(config, me);
1466
+ const me = await getAuthenticatedUser(config);
1467
+ const myTasks = await listMyTasks(config, me);
1510
1468
  const activeTask = myTasks.find((t) => {
1511
1469
  const labels = t.labels;
1512
1470
  return labels.includes("techunter:claimed") || labels.includes("techunter:changes-needed");
@@ -1515,37 +1473,65 @@ async function run3(input3, config) {
1515
1473
  return `You already have an active task: #${activeTask.number} "${activeTask.title}"
1516
1474
  Finish or submit it before claiming a new one.`;
1517
1475
  }
1518
- let spinner = ora4(`Claiming #${issue.number}\u2026`).start();
1476
+ let stashed = false;
1477
+ if (await hasUncommittedChanges()) {
1478
+ let choice;
1479
+ try {
1480
+ choice = await select3({
1481
+ message: "You have uncommitted changes. What would you like to do?",
1482
+ choices: [
1483
+ { name: "Stash changes and switch branch (restore with: git stash pop)", value: "stash" },
1484
+ { name: "Cancel", value: "cancel" }
1485
+ ]
1486
+ });
1487
+ } catch {
1488
+ choice = "cancel";
1489
+ }
1490
+ if (choice === "cancel") return "Cancelled.";
1491
+ await stash(`tch: before claiming #${issue.number}`);
1492
+ stashed = true;
1493
+ console.log(chalk5.dim(" Changes stashed. Run `git stash pop` after you finish this task to restore them."));
1494
+ }
1495
+ let spinner = ora3(`Claiming #${issue.number}\u2026`).start();
1519
1496
  await claimTask(config, issue.number, me);
1520
1497
  spinner.stop();
1521
- const workerBranch = makeWorkerBranchName(me);
1498
+ const taskBranch = makeTaskBranchName(issue.number, me);
1522
1499
  const taskBase = extractBaseCommit(issue.body);
1523
- spinner = ora4(`Switching to ${workerBranch}${taskBase ? ` from ${taskBase.slice(0, 7)}` : ""}\u2026`).start();
1500
+ spinner = ora3(`Creating branch ${taskBranch}${taskBase ? ` from ${taskBase.slice(0, 7)}` : ""}\u2026`).start();
1524
1501
  try {
1525
1502
  if (taskBase) {
1526
- await resetOrCreateBranch(workerBranch, taskBase);
1503
+ await checkoutFromCommit(taskBranch, taskBase);
1527
1504
  } else {
1528
- await switchToBranchOrCreate(workerBranch);
1505
+ await switchToBranchOrCreate(taskBranch);
1529
1506
  }
1530
1507
  spinner.stop();
1531
- spinner = ora4("Pushing worker branch\u2026").start();
1508
+ spinner = ora3("Pushing task branch\u2026").start();
1532
1509
  try {
1533
- await pushBranch(workerBranch);
1510
+ await pushBranch(taskBranch);
1534
1511
  spinner.stop();
1535
1512
  } catch {
1536
- spinner.warn("Could not push worker branch");
1513
+ spinner.warn("Could not push task branch \u2014 will push on submit");
1537
1514
  }
1538
- } catch {
1539
- spinner.warn(`Could not switch to ${workerBranch}`);
1515
+ } catch (err) {
1516
+ spinner.warn(`Could not switch to ${taskBranch}`);
1517
+ if (stashed) {
1518
+ try {
1519
+ await stashPop();
1520
+ console.log(chalk5.dim(" Restored stashed changes."));
1521
+ } catch {
1522
+ console.log(chalk5.yellow(" Warning: could not restore stash automatically. Run `git stash pop` manually."));
1523
+ }
1524
+ }
1525
+ throw err;
1540
1526
  }
1541
1527
  const baseCommit = await getCurrentCommit();
1542
- setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit } });
1543
- console.log(chalk8.green(`
1544
- Claimed! Branch: ${workerBranch} (base: ${baseCommit.slice(0, 7)})
1528
+ setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit, activeBranch: taskBranch } });
1529
+ console.log(chalk5.green(`
1530
+ Claimed! Branch: ${taskBranch} (base: ${baseCommit.slice(0, 7)})
1545
1531
  `));
1546
1532
  let openClaude;
1547
1533
  try {
1548
- openClaude = await select5({
1534
+ openClaude = await select3({
1549
1535
  message: "Open Claude Code for this task?",
1550
1536
  choices: [
1551
1537
  { name: "Yes, start coding now", value: true },
@@ -1555,12 +1541,58 @@ Finish or submit it before claiming a new one.`;
1555
1541
  } catch {
1556
1542
  openClaude = false;
1557
1543
  }
1558
- if (openClaude) await launchClaudeCode(issue, workerBranch);
1559
- return `Task #${issue.number} claimed. Branch: ${workerBranch}`;
1544
+ if (openClaude) await launchClaudeCode(issue, taskBranch);
1545
+ return `Task #${issue.number} claimed. Branch: ${taskBranch}`;
1560
1546
  } catch (err) {
1561
1547
  return `Error claiming task: ${err.message}`;
1562
1548
  }
1563
1549
  }
1550
+ if (action === "switch-fix") {
1551
+ const { getAuthenticatedUser: getAuthenticatedUser2 } = await Promise.resolve().then(() => (init_github(), github_exports));
1552
+ const me = await getAuthenticatedUser2(config);
1553
+ const taskBranch = issue.assignee ? makeTaskBranchName(issue.number, issue.assignee) : makeTaskBranchName(issue.number, me);
1554
+ let stashed = false;
1555
+ if (await hasUncommittedChanges()) {
1556
+ let choice;
1557
+ try {
1558
+ choice = await select3({
1559
+ message: "You have uncommitted changes. What would you like to do?",
1560
+ choices: [
1561
+ { name: "Stash changes and switch branch (restore with: git stash pop)", value: "stash" },
1562
+ { name: "Cancel", value: "cancel" }
1563
+ ]
1564
+ });
1565
+ } catch {
1566
+ choice = "cancel";
1567
+ }
1568
+ if (choice === "cancel") return "Cancelled.";
1569
+ await stash(`tch: before switching to ${taskBranch}`);
1570
+ stashed = true;
1571
+ console.log(chalk5.dim(" Changes stashed. Run `git stash pop` to restore them later."));
1572
+ }
1573
+ const spinner = ora3(`Switching to ${taskBranch}\u2026`).start();
1574
+ try {
1575
+ await switchToBranchOrCreate(taskBranch);
1576
+ spinner.stop();
1577
+ } catch (err) {
1578
+ spinner.warn(`Could not switch to ${taskBranch}: ${err.message}`);
1579
+ if (stashed) {
1580
+ try {
1581
+ await stashPop();
1582
+ console.log(chalk5.dim(" Restored stashed changes."));
1583
+ } catch {
1584
+ console.log(chalk5.yellow(" Run `git stash pop` manually to restore your changes."));
1585
+ }
1586
+ }
1587
+ return `Error: ${err.message}`;
1588
+ }
1589
+ const baseCommit = extractBaseCommit(issue.body) ?? await getCurrentCommit();
1590
+ setConfig({ taskState: { activeIssueNumber: issue.number, baseCommit, activeBranch: taskBranch } });
1591
+ console.log(chalk5.green(`
1592
+ Switched to ${taskBranch}. Fix the issues then run /submit.
1593
+ `));
1594
+ return `Switched to ${taskBranch} for task #${issue.number}.`;
1595
+ }
1564
1596
  if (action === "submit") return run({}, config);
1565
1597
  if (action === "close") return run2({ issue_number: issue.number }, config);
1566
1598
  return "Cancelled.";
@@ -1589,28 +1621,31 @@ async function execute3(input3, config) {
1589
1621
  if (activeTask) {
1590
1622
  return `You already have an active task: #${activeTask.number} "${activeTask.title}". Finish it before claiming a new one.`;
1591
1623
  }
1624
+ if (await hasUncommittedChanges()) {
1625
+ return "Cannot claim: you have uncommitted changes. Commit or stash them first (git stash).";
1626
+ }
1592
1627
  try {
1593
1628
  await claimTask(config, issueNumber, me);
1594
1629
  } catch (err) {
1595
1630
  return `Error claiming task: ${err.message}`;
1596
1631
  }
1597
- const workerBranch = makeWorkerBranchName(me);
1632
+ const taskBranch = makeTaskBranchName(issue.number, me);
1598
1633
  const taskBase = extractBaseCommit(issue.body);
1599
1634
  try {
1600
1635
  if (taskBase) {
1601
- await resetOrCreateBranch(workerBranch, taskBase);
1636
+ await checkoutFromCommit(taskBranch, taskBase);
1602
1637
  } else {
1603
- await switchToBranchOrCreate(workerBranch);
1638
+ await switchToBranchOrCreate(taskBranch);
1604
1639
  }
1605
1640
  } catch {
1606
1641
  }
1607
1642
  try {
1608
- await pushBranch(workerBranch);
1643
+ await pushBranch(taskBranch);
1609
1644
  } catch {
1610
1645
  }
1611
1646
  const baseCommit = await getCurrentCommit();
1612
- setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit } });
1613
- return `Task #${issueNumber} claimed. Branch: ${workerBranch} (base commit: ${baseCommit.slice(0, 7)})`;
1647
+ setConfig({ taskState: { activeIssueNumber: issueNumber, baseCommit, activeBranch: taskBranch } });
1648
+ return `Task #${issueNumber} claimed. Branch: ${taskBranch} (base commit: ${baseCommit.slice(0, 7)})`;
1614
1649
  }
1615
1650
  return `Unknown action: ${action}`;
1616
1651
  }
@@ -1625,14 +1660,14 @@ __export(new_task_exports, {
1625
1660
  terminal: () => terminal4
1626
1661
  });
1627
1662
  init_github();
1628
- import { select as select6, input as promptInput2 } from "@inquirer/prompts";
1663
+ import { select as select4, input as promptInput2 } from "@inquirer/prompts";
1629
1664
  import { writeFile, readFile, mkdtemp, rm } from "fs/promises";
1630
1665
  import { spawn as spawn2 } from "child_process";
1631
1666
  import { tmpdir } from "os";
1632
1667
  import path from "path";
1633
- import ora5 from "ora";
1634
- import chalk9 from "chalk";
1635
- import open2 from "open";
1668
+ import ora4 from "ora";
1669
+ import chalk6 from "chalk";
1670
+ import open from "open";
1636
1671
 
1637
1672
  // src/tools/new-task/prompts.ts
1638
1673
  var GUIDE_FORMAT = `
@@ -1662,9 +1697,9 @@ Previous guide:
1662
1697
  ${revise.previousGuide}` : `Write an implementation guide for this task: "${title}"`;
1663
1698
  return runSubAgentLoop(
1664
1699
  config,
1665
- "You are a senior engineer writing a brief task guide for a developer. Use scan_project and read_file to identify which files are relevant. Do NOT include code snippets or implementation details. When you have enough context, write the guide.\n\n" + GUIDE_FORMAT,
1700
+ "You are a senior engineer writing a brief task guide for a developer. Use list_files then grep_code to identify which files are relevant. Do NOT include code snippets or implementation details. When you have enough context, write the guide.\n\n" + GUIDE_FORMAT,
1666
1701
  userMessage,
1667
- ["scan_project", "read_file", "run_command", "ask_user"]
1702
+ ["list_files", "grep_code", "run_command", "ask_user"]
1668
1703
  );
1669
1704
  }
1670
1705
 
@@ -1685,6 +1720,77 @@ async function openInEditor(content) {
1685
1720
  await rm(dir, { recursive: true, force: true });
1686
1721
  }
1687
1722
  }
1723
+ async function resolveBaseAndTarget(config, me, interactive) {
1724
+ const currentBranch = await getCurrentBranch();
1725
+ if (isTaskBranch(currentBranch)) {
1726
+ if (await hasUncommittedChanges()) {
1727
+ if (!interactive) {
1728
+ throw new Error("Cannot create sub-task: you have uncommitted changes. Commit them first so the executor starts from the correct base.");
1729
+ }
1730
+ const { select: inquirerSelect } = await import("@inquirer/prompts");
1731
+ let choice;
1732
+ try {
1733
+ choice = await inquirerSelect({
1734
+ message: "You have uncommitted changes. The sub-task executor will start from the last commit \u2014 they won't see your current unsaved work.",
1735
+ choices: [
1736
+ { name: "Commit first (cancel and commit manually)", value: "cancel" },
1737
+ { name: "Continue anyway (executor starts without my unsaved changes)", value: "continue" }
1738
+ ]
1739
+ });
1740
+ } catch {
1741
+ choice = "cancel";
1742
+ }
1743
+ if (choice === "cancel") throw new Error("Cancelled. Commit your changes first, then create the sub-task.");
1744
+ }
1745
+ const baseCommit2 = await getCurrentCommit();
1746
+ return { baseCommit: baseCommit2, targetBranch: currentBranch, isSubtask: true };
1747
+ }
1748
+ let stashedForSync = false;
1749
+ if (await hasUncommittedChanges()) {
1750
+ if (!interactive) {
1751
+ throw new Error("Cannot create task: you have uncommitted changes. Commit or stash them first (git stash).");
1752
+ }
1753
+ const { select: inquirerSelect } = await import("@inquirer/prompts");
1754
+ let choice;
1755
+ try {
1756
+ choice = await inquirerSelect({
1757
+ message: "You have uncommitted changes. Syncing with main requires a clean working tree.",
1758
+ choices: [
1759
+ { name: "Stash changes and continue (restore with: git stash pop)", value: "stash" },
1760
+ { name: "Cancel", value: "cancel" }
1761
+ ]
1762
+ });
1763
+ } catch {
1764
+ choice = "cancel";
1765
+ }
1766
+ if (choice === "cancel") throw new Error("Cancelled.");
1767
+ await stash("tch: before creating new task");
1768
+ stashedForSync = true;
1769
+ console.log(chalk6.dim(" Changes stashed. Run `git stash pop` after creating the task."));
1770
+ }
1771
+ const baseBranch = config.baseBranch ?? "main";
1772
+ let baseCommit;
1773
+ const syncSpinner = ora4(`Syncing with ${baseBranch}\u2026`).start();
1774
+ try {
1775
+ await syncWithBase(baseBranch);
1776
+ baseCommit = await getCurrentCommit();
1777
+ syncSpinner.succeed(`Synced with ${baseBranch} (base: ${baseCommit.slice(0, 7)})`);
1778
+ } catch {
1779
+ syncSpinner.warn(`Could not sync with ${baseBranch} \u2014 recording remote HEAD as base`);
1780
+ try {
1781
+ baseCommit = await getRemoteHeadSha(baseBranch);
1782
+ } catch {
1783
+ }
1784
+ if (stashedForSync) {
1785
+ try {
1786
+ await stashPop();
1787
+ } catch {
1788
+ }
1789
+ throw new Error(`Could not sync with ${baseBranch}. Your changes have been restored from stash.`);
1790
+ }
1791
+ }
1792
+ return { baseCommit, targetBranch: makeWorkerBranchName(me), isSubtask: false };
1793
+ }
1688
1794
  var definition4 = {
1689
1795
  type: "function",
1690
1796
  function: {
@@ -1701,7 +1807,7 @@ var definition4 = {
1701
1807
  }
1702
1808
  };
1703
1809
  async function run4(input3, config) {
1704
- const authSpinner = ora5("Checking permissions\u2026").start();
1810
+ const authSpinner = ora4("Checking permissions\u2026").start();
1705
1811
  let me;
1706
1812
  let allowed;
1707
1813
  try {
@@ -1724,7 +1830,7 @@ async function run4(input3, config) {
1724
1830
  }
1725
1831
  if (!title) return "Cancelled.";
1726
1832
  }
1727
- const spinner = ora5("Scanning project and generating guide\u2026").start();
1833
+ const spinner = ora4("Scanning project and generating guide\u2026").start();
1728
1834
  let guide;
1729
1835
  try {
1730
1836
  guide = await generateGuide(config, title);
@@ -1733,16 +1839,16 @@ async function run4(input3, config) {
1733
1839
  spinner.stop();
1734
1840
  return `Error generating guide: ${err.message}`;
1735
1841
  }
1736
- const divider = chalk9.dim("\u2500".repeat(70));
1842
+ const divider = chalk6.dim("\u2500".repeat(70));
1737
1843
  for (; ; ) {
1738
1844
  console.log("\n" + divider);
1739
- console.log(chalk9.bold(" Generated guide preview"));
1845
+ console.log(chalk6.bold(" Generated guide preview"));
1740
1846
  console.log(divider);
1741
1847
  console.log(renderMarkdown(guide));
1742
1848
  console.log(divider + "\n");
1743
1849
  let action;
1744
1850
  try {
1745
- action = await select6({
1851
+ action = await select4({
1746
1852
  message: "Create this task?",
1747
1853
  choices: [
1748
1854
  { name: "Yes, create task", value: "create" },
@@ -1760,7 +1866,7 @@ async function run4(input3, config) {
1760
1866
  try {
1761
1867
  guide = await openInEditor(guide);
1762
1868
  } catch (err) {
1763
- console.log(chalk9.yellow(` Editor error: ${err.message}`));
1869
+ console.log(chalk6.yellow(` Editor error: ${err.message}`));
1764
1870
  }
1765
1871
  continue;
1766
1872
  }
@@ -1771,35 +1877,32 @@ async function run4(input3, config) {
1771
1877
  return "Cancelled.";
1772
1878
  }
1773
1879
  if (!feedback) continue;
1774
- const reviseSpinner = ora5("Revising guide\u2026").start();
1880
+ const reviseSpinner = ora4("Revising guide\u2026").start();
1775
1881
  try {
1776
1882
  guide = await generateGuide(config, title, { feedback, previousGuide: guide });
1777
1883
  reviseSpinner.stop();
1778
1884
  } catch (err) {
1779
1885
  reviseSpinner.stop();
1780
- console.log(chalk9.yellow(` Revision error: ${err.message}`));
1886
+ console.log(chalk6.yellow(` Revision error: ${err.message}`));
1781
1887
  }
1782
1888
  }
1783
- const baseBranch = config.baseBranch ?? "main";
1784
1889
  let baseCommit;
1785
- const syncSpinner = ora5(`Syncing with ${baseBranch}\u2026`).start();
1890
+ let targetBranch;
1891
+ let isSubtask;
1786
1892
  try {
1787
- await syncWithBase(baseBranch);
1788
- baseCommit = await getCurrentCommit();
1789
- syncSpinner.succeed(`Synced with ${baseBranch} (base: ${baseCommit.slice(0, 7)})`);
1790
- } catch {
1791
- syncSpinner.warn(`Could not sync with ${baseBranch} \u2014 recording remote HEAD as base`);
1792
- try {
1793
- baseCommit = await getRemoteHeadSha(baseBranch);
1794
- } catch {
1795
- }
1893
+ ({ baseCommit, targetBranch, isSubtask } = await resolveBaseAndTarget(config, me, true));
1894
+ } catch (err) {
1895
+ return err.message;
1796
1896
  }
1797
- const createSpinner = ora5(`Creating "${title}"\u2026`).start();
1897
+ if (isSubtask) {
1898
+ console.log(chalk6.dim(` Sub-task: will target branch ${chalk6.cyan(targetBranch)} (base: ${baseCommit?.slice(0, 7) ?? "HEAD"})`));
1899
+ }
1900
+ const createSpinner = ora4(`Creating "${title}"\u2026`).start();
1798
1901
  let htmlUrl;
1799
1902
  let issueNumber;
1800
1903
  let issueTitle;
1801
1904
  try {
1802
- const issue = await createTask(config, title, guide, baseCommit);
1905
+ const issue = await createTask(config, title, guide, baseCommit, targetBranch);
1803
1906
  createSpinner.stop();
1804
1907
  htmlUrl = issue.htmlUrl;
1805
1908
  issueNumber = issue.number;
@@ -1808,19 +1911,19 @@ async function run4(input3, config) {
1808
1911
  createSpinner.stop();
1809
1912
  return `Error: ${err.message}`;
1810
1913
  }
1811
- console.log(chalk9.green(`
1914
+ console.log(chalk6.green(`
1812
1915
  Created #${issueNumber} "${issueTitle}"
1813
- ${chalk9.dim(htmlUrl)}
1916
+ ${chalk6.dim(htmlUrl)}
1814
1917
  `));
1815
1918
  try {
1816
- const openBrowser = await select6({
1919
+ const openBrowser = await select4({
1817
1920
  message: "Open issue in browser?",
1818
1921
  choices: [
1819
1922
  { name: "Yes", value: true },
1820
1923
  { name: "No", value: false }
1821
1924
  ]
1822
1925
  });
1823
- if (openBrowser) await open2(htmlUrl);
1926
+ if (openBrowser) await open(htmlUrl);
1824
1927
  } catch {
1825
1928
  }
1826
1929
  return `Created #${issueNumber} "${issueTitle}" \u2014 ${htmlUrl}`;
@@ -1836,19 +1939,9 @@ async function execute4(input3, config) {
1836
1939
  if (feedback) {
1837
1940
  guide = await generateGuide(config, title, { feedback, previousGuide: guide });
1838
1941
  }
1839
- const baseBranch = config.baseBranch ?? "main";
1840
- let baseCommit;
1841
- try {
1842
- await syncWithBase(baseBranch);
1843
- baseCommit = await getCurrentCommit();
1844
- } catch {
1845
- try {
1846
- baseCommit = await getRemoteHeadSha(baseBranch);
1847
- } catch {
1848
- }
1849
- }
1942
+ const { baseCommit, targetBranch } = await resolveBaseAndTarget(config, me, false);
1850
1943
  try {
1851
- const issue = await createTask(config, title, guide, baseCommit);
1944
+ const issue = await createTask(config, title, guide, baseCommit, targetBranch);
1852
1945
  return `Created #${issue.number} "${issue.title}" \u2014 ${issue.htmlUrl}
1853
1946
 
1854
1947
  Guide:
@@ -1868,7 +1961,7 @@ __export(my_status_exports, {
1868
1961
  terminal: () => terminal5
1869
1962
  });
1870
1963
  init_github();
1871
- import ora6 from "ora";
1964
+ import ora5 from "ora";
1872
1965
  var definition5 = {
1873
1966
  type: "function",
1874
1967
  function: {
@@ -1878,7 +1971,7 @@ var definition5 = {
1878
1971
  }
1879
1972
  };
1880
1973
  async function run5(_input, config) {
1881
- const spinner = ora6("Fetching your tasks\u2026").start();
1974
+ const spinner = ora5("Fetching your tasks\u2026").start();
1882
1975
  try {
1883
1976
  const me = await getAuthenticatedUser(config);
1884
1977
  const tasks = await listMyTasks(config, me);
@@ -1898,120 +1991,193 @@ var terminal5 = true;
1898
1991
  // src/tools/review/index.ts
1899
1992
  var review_exports = {};
1900
1993
  __export(review_exports, {
1994
+ definition: () => definition8,
1995
+ execute: () => execute8,
1996
+ run: () => run8,
1997
+ terminal: () => terminal8
1998
+ });
1999
+ init_github();
2000
+ import chalk9 from "chalk";
2001
+ import ora8 from "ora";
2002
+ import { select as select7 } from "@inquirer/prompts";
2003
+
2004
+ // src/tools/accept/index.ts
2005
+ var accept_exports = {};
2006
+ __export(accept_exports, {
1901
2007
  definition: () => definition6,
1902
2008
  execute: () => execute6,
1903
2009
  run: () => run6,
1904
2010
  terminal: () => terminal6
1905
2011
  });
1906
2012
  init_github();
1907
- import ora7 from "ora";
2013
+ import chalk7 from "chalk";
2014
+ import { select as select5 } from "@inquirer/prompts";
2015
+ import ora6 from "ora";
1908
2016
  var definition6 = {
1909
2017
  type: "function",
1910
2018
  function: {
1911
- name: "review",
1912
- description: "List tasks waiting for your review (submitted by others, created by you). Equivalent to /review.",
1913
- parameters: { type: "object", properties: {}, required: [] }
2019
+ name: "accept",
2020
+ description: "Accept an in-review task: merges the PR into the target branch and closes the issue.",
2021
+ parameters: {
2022
+ type: "object",
2023
+ properties: {
2024
+ issue_number: { type: "number", description: "GitHub issue number to accept" }
2025
+ },
2026
+ required: ["issue_number"]
2027
+ }
1914
2028
  }
1915
2029
  };
1916
- async function run6(_input, config) {
1917
- const spinner = ora7("Loading tasks for review\u2026").start();
2030
+ async function run6(input3, config) {
2031
+ let issueNumber = input3["issue_number"];
2032
+ if (!issueNumber) {
2033
+ const spinner3 = ora6("Loading tasks for review\u2026").start();
2034
+ let tasks;
2035
+ let me;
2036
+ try {
2037
+ me = await getAuthenticatedUser(config);
2038
+ tasks = await listTasksForReview(config, me);
2039
+ spinner3.stop();
2040
+ } catch (err) {
2041
+ spinner3.stop();
2042
+ return `Error: ${err.message}`;
2043
+ }
2044
+ if (tasks.length === 0) return "No tasks pending review.";
2045
+ try {
2046
+ issueNumber = await select5({
2047
+ message: "Which task to accept?",
2048
+ choices: tasks.map((t) => ({
2049
+ name: `#${t.number} @${t.assignee ?? "\u2014"} ${t.title}`,
2050
+ value: t.number
2051
+ }))
2052
+ });
2053
+ } catch {
2054
+ return "Cancelled.";
2055
+ }
2056
+ }
2057
+ const spinner2 = ora6("Verifying permissions\u2026").start();
2058
+ let me2;
2059
+ let issue;
1918
2060
  try {
1919
- const me = await getAuthenticatedUser(config);
1920
- const tasks = await listTasksForReview(config, me);
1921
- spinner.stop();
1922
- if (tasks.length === 0) return `No tasks pending review for @${me}.`;
1923
- const lines = tasks.map((t) => ` #${t.number} [in-review] @${t.assignee ?? "\u2014"} ${t.title}`);
1924
- return `Tasks pending review (created by @${me}):
1925
- ${lines.join("\n")}`;
2061
+ [me2, issue] = await Promise.all([
2062
+ getAuthenticatedUser(config),
2063
+ getTask(config, issueNumber)
2064
+ ]);
2065
+ spinner2.stop();
1926
2066
  } catch (err) {
1927
- spinner.stop();
2067
+ spinner2.stop();
1928
2068
  return `Error: ${err.message}`;
1929
2069
  }
1930
- }
1931
- var execute6 = run6;
1932
- var terminal6 = true;
1933
-
1934
- // src/tools/refresh/index.ts
1935
- var refresh_exports = {};
1936
- __export(refresh_exports, {
1937
- definition: () => definition7,
1938
- execute: () => execute7,
1939
- run: () => run7,
1940
- terminal: () => terminal7
1941
- });
1942
- var definition7 = {
1943
- type: "function",
1944
- function: {
1945
- name: "refresh",
1946
- description: "Reload and display the full task list. Equivalent to /refresh.",
1947
- parameters: { type: "object", properties: {}, required: [] }
1948
- }
1949
- };
1950
- async function run7(_input, config) {
1951
- const tasks = await printTaskList(config);
1952
- if (tasks.length === 0) return "No tasks found.";
1953
- const lines = tasks.map((t) => {
1954
- const status = getStatus(t);
1955
- const assignee = t.assignee ? `@${t.assignee}` : "\u2014";
1956
- return `#${t.number} [${status}] ${assignee} ${t.title}`;
1957
- });
1958
- return `Tasks (${tasks.length}):
1959
- ${lines.join("\n")}`;
1960
- }
1961
- var execute7 = run7;
1962
- var terminal7 = true;
1963
-
1964
- // src/tools/open-code/index.ts
1965
- var open_code_exports = {};
1966
- __export(open_code_exports, {
1967
- definition: () => definition8,
1968
- execute: () => execute8,
1969
- run: () => run8,
1970
- terminal: () => terminal8
1971
- });
1972
- init_github();
1973
- var definition8 = {
1974
- type: "function",
1975
- function: {
1976
- name: "open_code",
1977
- description: "Launch Claude Code for the current task branch. Equivalent to /code.",
1978
- parameters: { type: "object", properties: {}, required: [] }
2070
+ if (issue.author && issue.author !== me2) {
2071
+ return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
1979
2072
  }
1980
- };
1981
- async function run8(_input, config) {
1982
- let branch;
2073
+ let confirmed;
1983
2074
  try {
1984
- branch = await getCurrentBranch();
2075
+ confirmed = await select5({
2076
+ message: `Merge PR for #${issueNumber} and close issue?`,
2077
+ choices: [
2078
+ { name: "Yes, accept", value: true },
2079
+ { name: "Cancel", value: false }
2080
+ ]
2081
+ });
2082
+ } catch {
2083
+ return "Cancelled.";
2084
+ }
2085
+ if (!confirmed) return "Cancelled.";
2086
+ const spinner = ora6(`Merging PR for #${issueNumber}\u2026`).start();
2087
+ let result;
2088
+ try {
2089
+ result = await acceptTask(config, issueNumber);
2090
+ spinner.succeed(`PR #${result.prNumber} merged \u2192 ${chalk7.cyan(result.baseBranch)}`);
1985
2091
  } catch (err) {
2092
+ spinner.fail("Failed");
1986
2093
  return `Error: ${err.message}`;
1987
2094
  }
1988
- const match = branch.match(/^task-(\d+)-/);
1989
- if (!match) return `Not on a task branch (current: ${branch}).`;
1990
- const issueNum = parseInt(match[1], 10);
1991
- let issue;
2095
+ const mergedIntoTaskBranch = isTaskBranch(result.baseBranch);
2096
+ if (!mergedIntoTaskBranch) {
2097
+ const baseBranch = config.baseBranch ?? "main";
2098
+ let pushToMain;
2099
+ try {
2100
+ pushToMain = await select5({
2101
+ message: `Push ${chalk7.cyan(result.baseBranch)} \u2192 ${chalk7.cyan(baseBranch)}?`,
2102
+ choices: [
2103
+ { name: `Yes, push to ${baseBranch}`, value: true },
2104
+ { name: "No, keep in worker branch", value: false }
2105
+ ]
2106
+ });
2107
+ } catch {
2108
+ pushToMain = false;
2109
+ }
2110
+ if (pushToMain) {
2111
+ const mergeSpinner = ora6(`Merging ${result.baseBranch} \u2192 ${baseBranch}\u2026`).start();
2112
+ try {
2113
+ await mergeWorkerIntoBase(config, result.baseBranch, baseBranch);
2114
+ mergeSpinner.succeed(`Merged ${result.baseBranch} \u2192 ${baseBranch}`);
2115
+ } catch (err) {
2116
+ mergeSpinner.fail(`Could not merge to ${baseBranch}: ${err.message}`);
2117
+ }
2118
+ }
2119
+ }
2120
+ let updateWiki = false;
1992
2121
  try {
1993
- issue = await getTask(config, issueNum);
2122
+ updateWiki = await select5({
2123
+ message: "Update TECHUNTER.md project overview?",
2124
+ choices: [
2125
+ { name: "Yes, regenerate", value: true },
2126
+ { name: "No, skip", value: false }
2127
+ ]
2128
+ });
2129
+ } catch {
2130
+ }
2131
+ if (updateWiki) {
2132
+ const wikiSpinner = ora6("Regenerating TECHUNTER.md\u2026").start();
2133
+ try {
2134
+ const content = await generateWiki(config);
2135
+ await upsertRepoFile(config, "TECHUNTER.md", content, "docs: update TECHUNTER.md project overview");
2136
+ wikiSpinner.succeed("TECHUNTER.md updated");
2137
+ } catch (err) {
2138
+ wikiSpinner.fail(`Wiki update failed: ${err.message}`);
2139
+ }
2140
+ }
2141
+ const mergeTarget = mergedIntoTaskBranch ? `${result.baseBranch} (sub-task merged, no push to main)` : result.baseBranch;
2142
+ return `Task #${issueNumber} accepted.
2143
+ PR #${result.prNumber} merged \u2192 ${mergeTarget}
2144
+ Issue closed.`;
2145
+ }
2146
+ async function execute6(input3, config) {
2147
+ const issueNumber = input3["issue_number"];
2148
+ const [me, issue] = await Promise.all([
2149
+ getAuthenticatedUser(config),
2150
+ getTask(config, issueNumber)
2151
+ ]);
2152
+ if (issue.author && issue.author !== me) {
2153
+ return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
2154
+ }
2155
+ const spinner = ora6(`Merging PR for #${issueNumber}\u2026`).start();
2156
+ try {
2157
+ const result = await acceptTask(config, issueNumber);
2158
+ spinner.stop();
2159
+ return `Task #${issueNumber} accepted.
2160
+ PR #${result.prNumber} merged \u2192 ${result.baseBranch}
2161
+ Issue closed.`;
1994
2162
  } catch (err) {
2163
+ spinner.stop();
1995
2164
  return `Error: ${err.message}`;
1996
2165
  }
1997
- await launchClaudeCode(issue, branch);
1998
- return "Claude Code session ended.";
1999
2166
  }
2000
- var execute8 = run8;
2001
- var terminal8 = true;
2167
+ var terminal6 = true;
2002
2168
 
2003
2169
  // src/tools/reject/index.ts
2004
2170
  var reject_exports = {};
2005
2171
  __export(reject_exports, {
2006
- definition: () => definition9,
2007
- execute: () => execute9,
2008
- run: () => run9,
2009
- terminal: () => terminal9
2172
+ definition: () => definition7,
2173
+ execute: () => execute7,
2174
+ run: () => run7,
2175
+ terminal: () => terminal7
2010
2176
  });
2011
2177
  init_github();
2012
- import chalk10 from "chalk";
2013
- import { select as select7, input as promptInput3 } from "@inquirer/prompts";
2014
- import ora8 from "ora";
2178
+ import chalk8 from "chalk";
2179
+ import { select as select6, input as promptInput3 } from "@inquirer/prompts";
2180
+ import ora7 from "ora";
2015
2181
 
2016
2182
  // src/tools/reject/prompts.ts
2017
2183
  var REJECTION_FORMAT = `
@@ -2034,15 +2200,15 @@ Clear instruction on what to fix and how to re-submit (via /submit).
2034
2200
  async function generateRejectionComment(config, issueNumber, userFeedback) {
2035
2201
  return runSubAgentLoop(
2036
2202
  config,
2037
- "You are a senior engineer writing a structured code review rejection comment. Use get_task to read the acceptance criteria, get_diff or read_file to inspect the implementation, and get_comments to see prior discussion. Then write the rejection comment.\n\n" + REJECTION_FORMAT,
2203
+ "You are a senior engineer writing a structured code review rejection comment. Use get_task to read the acceptance criteria, get_diff or grep_code to inspect the implementation, and get_comments to see prior discussion. Then write the rejection comment.\n\n" + REJECTION_FORMAT,
2038
2204
  `Write a rejection comment for issue #${issueNumber}.
2039
2205
  Reviewer feedback: ${userFeedback}`,
2040
- ["get_task", "get_comments", "get_diff", "read_file"]
2206
+ ["get_task", "get_comments", "get_diff", "grep_code"]
2041
2207
  );
2042
2208
  }
2043
2209
 
2044
2210
  // src/tools/reject/index.ts
2045
- var definition9 = {
2211
+ var definition7 = {
2046
2212
  type: "function",
2047
2213
  function: {
2048
2214
  name: "reject",
@@ -2057,7 +2223,7 @@ var definition9 = {
2057
2223
  }
2058
2224
  }
2059
2225
  };
2060
- async function run9(input3, config) {
2226
+ async function run7(input3, config) {
2061
2227
  const issueNumber = input3["issue_number"];
2062
2228
  const [me, issue] = await Promise.all([
2063
2229
  getAuthenticatedUser(config),
@@ -2075,9 +2241,9 @@ async function run9(input3, config) {
2075
2241
  return "Cancelled.";
2076
2242
  }
2077
2243
  if (!feedback.trim()) return "Cancelled.";
2078
- const divider = chalk10.dim("\u2500".repeat(70));
2244
+ const divider = chalk8.dim("\u2500".repeat(70));
2079
2245
  for (; ; ) {
2080
- const spinner = ora8("Generating rejection comment\u2026").start();
2246
+ const spinner = ora7("Generating rejection comment\u2026").start();
2081
2247
  let comment;
2082
2248
  try {
2083
2249
  comment = await generateRejectionComment(config, issueNumber, feedback);
@@ -2087,13 +2253,13 @@ async function run9(input3, config) {
2087
2253
  return `Error generating comment: ${err.message}`;
2088
2254
  }
2089
2255
  console.log("\n" + divider);
2090
- console.log(chalk10.bold(` Rejection preview \u2014 issue #${issueNumber}`));
2256
+ console.log(chalk8.bold(` Rejection preview \u2014 issue #${issueNumber}`));
2091
2257
  console.log(divider);
2092
2258
  console.log(renderMarkdown(comment));
2093
2259
  console.log(divider + "\n");
2094
2260
  let decision;
2095
2261
  try {
2096
- decision = await select7({
2262
+ decision = await select6({
2097
2263
  message: `Post rejection and mark #${issueNumber} as changes-needed?`,
2098
2264
  choices: [
2099
2265
  { name: "Post & Reject", value: "yes" },
@@ -2113,7 +2279,7 @@ async function run9(input3, config) {
2113
2279
  }
2114
2280
  continue;
2115
2281
  }
2116
- let spinner2 = ora8(`Posting rejection comment on #${issueNumber}\u2026`).start();
2282
+ let spinner2 = ora7(`Posting rejection comment on #${issueNumber}\u2026`).start();
2117
2283
  try {
2118
2284
  await postComment(config, issueNumber, comment);
2119
2285
  spinner2.stop();
@@ -2121,7 +2287,7 @@ async function run9(input3, config) {
2121
2287
  spinner2.stop();
2122
2288
  return `Error posting comment: ${err.message}`;
2123
2289
  }
2124
- spinner2 = ora8(`Marking #${issueNumber} as changes-needed\u2026`).start();
2290
+ spinner2 = ora7(`Marking #${issueNumber} as changes-needed\u2026`).start();
2125
2291
  try {
2126
2292
  await rejectTask(config, issueNumber);
2127
2293
  spinner2.stop();
@@ -2132,7 +2298,7 @@ async function run9(input3, config) {
2132
2298
  return `Task #${issueNumber} rejected. Label changed to changes-needed.`;
2133
2299
  }
2134
2300
  }
2135
- async function execute9(input3, config) {
2301
+ async function execute7(input3, config) {
2136
2302
  const issueNumber = input3["issue_number"];
2137
2303
  const feedback = input3["feedback"];
2138
2304
  const [me, issue] = await Promise.all([
@@ -2163,171 +2329,202 @@ async function execute9(input3, config) {
2163
2329
  Comment posted:
2164
2330
  ${comment}`;
2165
2331
  }
2166
- var terminal9 = true;
2332
+ var terminal7 = true;
2167
2333
 
2168
- // src/tools/accept/index.ts
2169
- var accept_exports = {};
2170
- __export(accept_exports, {
2171
- definition: () => definition10,
2172
- execute: () => execute10,
2173
- run: () => run10,
2174
- terminal: () => terminal10
2175
- });
2176
- init_github();
2177
- import chalk11 from "chalk";
2178
- import { select as select8 } from "@inquirer/prompts";
2179
- import ora9 from "ora";
2180
- var definition10 = {
2334
+ // src/tools/review/index.ts
2335
+ var definition8 = {
2181
2336
  type: "function",
2182
2337
  function: {
2183
- name: "accept",
2184
- description: "Accept an in-review task: merges the PR into your worker branch and closes the issue.",
2185
- parameters: {
2186
- type: "object",
2187
- properties: {
2188
- issue_number: { type: "number", description: "GitHub issue number to accept" }
2189
- },
2190
- required: ["issue_number"]
2191
- }
2338
+ name: "review",
2339
+ description: "List tasks waiting for your review (submitted by others, created by you), then let you accept or reject one. Equivalent to /review.",
2340
+ parameters: { type: "object", properties: {}, required: [] }
2192
2341
  }
2193
2342
  };
2194
- async function run10(input3, config) {
2195
- let issueNumber = input3["issue_number"];
2196
- if (!issueNumber) {
2197
- const spinner3 = ora9("Loading tasks for review\u2026").start();
2198
- let tasks;
2199
- let me;
2200
- try {
2201
- me = await getAuthenticatedUser(config);
2202
- tasks = await listTasksForReview(config, me);
2203
- spinner3.stop();
2204
- } catch (err) {
2205
- spinner3.stop();
2206
- return `Error: ${err.message}`;
2207
- }
2208
- if (tasks.length === 0) return "No tasks pending review.";
2209
- try {
2210
- issueNumber = await select8({
2211
- message: "Which task to accept?",
2212
- choices: tasks.map((t) => ({
2213
- name: `#${t.number} @${t.assignee ?? "\u2014"} ${t.title}`,
2214
- value: t.number
2215
- }))
2216
- });
2217
- } catch {
2218
- return "Cancelled.";
2219
- }
2220
- }
2221
- const spinner2 = ora9("Verifying permissions\u2026").start();
2222
- let me2;
2223
- let issue;
2343
+ async function run8(_input, config) {
2344
+ const spinner = ora8("Loading tasks for review\u2026").start();
2345
+ let me;
2346
+ let tasks;
2224
2347
  try {
2225
- [me2, issue] = await Promise.all([
2226
- getAuthenticatedUser(config),
2227
- getTask(config, issueNumber)
2228
- ]);
2229
- spinner2.stop();
2348
+ me = await getAuthenticatedUser(config);
2349
+ tasks = await listTasksForReview(config, me);
2350
+ spinner.stop();
2230
2351
  } catch (err) {
2231
- spinner2.stop();
2352
+ spinner.stop();
2232
2353
  return `Error: ${err.message}`;
2233
2354
  }
2234
- if (issue.author && issue.author !== me2) {
2235
- return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
2236
- }
2237
- const targetBranch = makeWorkerBranchName(me2);
2238
- let confirmed;
2355
+ if (tasks.length === 0) return `No tasks pending review for @${me}.`;
2356
+ let issueNumber;
2239
2357
  try {
2240
- confirmed = await select8({
2241
- message: `Merge PR for #${issueNumber} into ${chalk11.cyan(targetBranch)} and close issue?`,
2242
- choices: [
2243
- { name: "Yes, accept", value: true },
2244
- { name: "Cancel", value: false }
2245
- ]
2358
+ issueNumber = await select7({
2359
+ message: "Select a task to review:",
2360
+ choices: tasks.map((t) => ({
2361
+ name: `#${String(t.number).padEnd(4)} @${t.assignee ?? "\u2014"} ${t.title}`,
2362
+ value: t.number
2363
+ }))
2246
2364
  });
2247
2365
  } catch {
2248
2366
  return "Cancelled.";
2249
2367
  }
2250
- if (!confirmed) return "Cancelled.";
2251
- const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2252
- let result;
2368
+ const spinner2 = ora8(`Loading #${issueNumber}\u2026`).start();
2369
+ let pr;
2253
2370
  try {
2254
- const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2255
- result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2256
- spinner.succeed(`PR #${result.prNumber} merged into ${targetBranch}`);
2371
+ pr = await getTaskPR(config, issueNumber);
2372
+ spinner2.stop();
2257
2373
  } catch (err) {
2258
- spinner.fail("Failed");
2259
- return `Error: ${err.message}`;
2374
+ spinner2.stop();
2375
+ return `Error loading PR: ${err.message}`;
2260
2376
  }
2261
- const baseBranch = config.baseBranch ?? "main";
2262
- let pushToMain;
2263
- try {
2264
- pushToMain = await select8({
2265
- message: `Push ${chalk11.cyan(targetBranch)} \u2192 ${chalk11.cyan(baseBranch)}?`,
2266
- choices: [
2267
- { name: `Yes, push to ${baseBranch}`, value: true },
2268
- { name: "No, keep in worker branch", value: false }
2269
- ]
2270
- });
2271
- } catch {
2272
- pushToMain = false;
2377
+ const divider = chalk9.dim("\u2500".repeat(70));
2378
+ console.log("\n" + divider);
2379
+ if (pr) {
2380
+ console.log(chalk9.bold(` PR #${pr.number}`) + " " + chalk9.dim(pr.url));
2381
+ console.log(divider);
2382
+ console.log(renderMarkdown(pr.body));
2383
+ } else {
2384
+ console.log(chalk9.yellow(` No open PR found for task #${issueNumber}`));
2273
2385
  }
2274
- if (pushToMain) {
2275
- const mergeSpinner = ora9(`Merging ${targetBranch} \u2192 ${baseBranch}\u2026`).start();
2386
+ console.log(divider + "\n");
2387
+ for (; ; ) {
2388
+ let action;
2276
2389
  try {
2277
- await mergeWorkerIntoBase(config, targetBranch, baseBranch);
2278
- mergeSpinner.succeed(`Merged ${targetBranch} \u2192 ${baseBranch}`);
2279
- } catch (err) {
2280
- mergeSpinner.fail(`Could not merge to ${baseBranch}: ${err.message}`);
2390
+ action = await select7({
2391
+ message: "Review action:",
2392
+ choices: [
2393
+ ...pr ? [{ name: "View diff", value: "diff" }] : [],
2394
+ { name: chalk9.green("Accept") + " \u2014 merge PR and close issue", value: "accept" },
2395
+ { name: chalk9.red("Reject") + " \u2014 request changes", value: "reject" },
2396
+ { name: "Nothing, just viewing", value: "none" }
2397
+ ]
2398
+ });
2399
+ } catch {
2400
+ return "Cancelled.";
2401
+ }
2402
+ if (action === "none") return `Viewed task #${issueNumber}.`;
2403
+ if (action === "accept") return run6({ issue_number: issueNumber }, config);
2404
+ if (action === "reject") return run7({ issue_number: issueNumber }, config);
2405
+ if (action === "diff") {
2406
+ const diffSpinner = ora8("Fetching diff\u2026").start();
2407
+ let diff;
2408
+ try {
2409
+ diff = await getTaskPRDiff(config, pr.number);
2410
+ diffSpinner.stop();
2411
+ } catch (err) {
2412
+ diffSpinner.stop();
2413
+ console.log(chalk9.red(`Error fetching diff: ${err.message}`));
2414
+ continue;
2415
+ }
2416
+ console.log("\n" + divider);
2417
+ for (const line of diff.split("\n")) {
2418
+ if (line.startsWith("+") && !line.startsWith("+++")) {
2419
+ process.stdout.write(chalk9.green(line) + "\n");
2420
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
2421
+ process.stdout.write(chalk9.red(line) + "\n");
2422
+ } else if (line.startsWith("@@")) {
2423
+ process.stdout.write(chalk9.cyan(line) + "\n");
2424
+ } else if (line.startsWith("diff ") || line.startsWith("index ") || line.startsWith("+++") || line.startsWith("---")) {
2425
+ process.stdout.write(chalk9.bold(line) + "\n");
2426
+ } else {
2427
+ process.stdout.write(line + "\n");
2428
+ }
2429
+ }
2430
+ console.log(divider + "\n");
2281
2431
  }
2282
- }
2283
- return `Task #${issueNumber} accepted.
2284
- PR #${result.prNumber} merged \u2192 ${targetBranch}${pushToMain ? ` \u2192 ${baseBranch}` : ""}
2285
- Issue closed.`;
2286
- }
2287
- async function execute10(input3, config) {
2288
- const issueNumber = input3["issue_number"];
2289
- const [me, issue] = await Promise.all([
2290
- getAuthenticatedUser(config),
2291
- getTask(config, issueNumber)
2292
- ]);
2293
- if (issue.author && issue.author !== me) {
2294
- return `Permission denied: only the task author (@${issue.author}) can accept task #${issueNumber}.`;
2295
- }
2296
- const targetBranch = makeWorkerBranchName(me);
2297
- const spinner = ora9(`Merging PR for #${issueNumber}\u2026`).start();
2298
- try {
2299
- const assigneeWorkerBranch = issue.assignee ? makeWorkerBranchName(issue.assignee) : void 0;
2300
- const result = await acceptTask(config, issueNumber, assigneeWorkerBranch);
2301
- spinner.stop();
2302
- return `Task #${issueNumber} accepted.
2303
- PR #${result.prNumber} merged \u2192 ${targetBranch}
2304
- Issue closed.`;
2305
- } catch (err) {
2306
- spinner.stop();
2307
- return `Error: ${err.message}`;
2308
2432
  }
2309
2433
  }
2310
- var terminal10 = true;
2434
+ var execute8 = run8;
2435
+ var terminal8 = true;
2311
2436
 
2312
- // src/tools/edit-task/index.ts
2313
- var edit_task_exports = {};
2314
- __export(edit_task_exports, {
2315
- definition: () => definition11,
2316
- execute: () => execute11,
2317
- run: () => run11,
2318
- terminal: () => terminal11
2437
+ // src/tools/refresh/index.ts
2438
+ var refresh_exports = {};
2439
+ __export(refresh_exports, {
2440
+ definition: () => definition9,
2441
+ execute: () => execute9,
2442
+ run: () => run9,
2443
+ terminal: () => terminal9
2319
2444
  });
2320
- init_github();
2321
- import { select as select9, input as promptInput4 } from "@inquirer/prompts";
2322
- import ora10 from "ora";
2323
- var definition11 = {
2445
+ var definition9 = {
2324
2446
  type: "function",
2325
2447
  function: {
2326
- name: "edit_task",
2327
- description: "Edit the title and/or body of an existing task (GitHub Issue). Equivalent to /edit.",
2328
- parameters: {
2329
- type: "object",
2330
- properties: {
2448
+ name: "refresh",
2449
+ description: "Reload and display the full task list. Equivalent to /refresh.",
2450
+ parameters: { type: "object", properties: {}, required: [] }
2451
+ }
2452
+ };
2453
+ async function run9(_input, config) {
2454
+ const tasks = await printTaskList(config);
2455
+ if (tasks.length === 0) return "No tasks found.";
2456
+ const lines = tasks.map((t) => {
2457
+ const status = getStatus(t);
2458
+ const assignee = t.assignee ? `@${t.assignee}` : "\u2014";
2459
+ return `#${t.number} [${status}] ${assignee} ${t.title}`;
2460
+ });
2461
+ return `Tasks (${tasks.length}):
2462
+ ${lines.join("\n")}`;
2463
+ }
2464
+ var execute9 = run9;
2465
+ var terminal9 = true;
2466
+
2467
+ // src/tools/open-code/index.ts
2468
+ var open_code_exports = {};
2469
+ __export(open_code_exports, {
2470
+ definition: () => definition10,
2471
+ execute: () => execute10,
2472
+ run: () => run10,
2473
+ terminal: () => terminal10
2474
+ });
2475
+ init_github();
2476
+ var definition10 = {
2477
+ type: "function",
2478
+ function: {
2479
+ name: "open_code",
2480
+ description: "Launch Claude Code for the current task branch. Equivalent to /code.",
2481
+ parameters: { type: "object", properties: {}, required: [] }
2482
+ }
2483
+ };
2484
+ async function run10(_input, config) {
2485
+ let branch;
2486
+ try {
2487
+ branch = await getCurrentBranch();
2488
+ } catch (err) {
2489
+ return `Error: ${err.message}`;
2490
+ }
2491
+ let issueNum = getConfig().taskState?.activeIssueNumber;
2492
+ if (!issueNum) {
2493
+ const found = await getIssueNumberFromBranch(config, branch);
2494
+ if (!found) return `No active task found (current branch: ${branch}).`;
2495
+ issueNum = found.issueNumber;
2496
+ }
2497
+ let issue;
2498
+ try {
2499
+ issue = await getTask(config, issueNum);
2500
+ } catch (err) {
2501
+ return `Error: ${err.message}`;
2502
+ }
2503
+ await launchClaudeCode(issue, branch);
2504
+ return "Claude Code session ended.";
2505
+ }
2506
+ var execute10 = run10;
2507
+ var terminal10 = true;
2508
+
2509
+ // src/tools/edit-task/index.ts
2510
+ var edit_task_exports = {};
2511
+ __export(edit_task_exports, {
2512
+ definition: () => definition11,
2513
+ execute: () => execute11,
2514
+ run: () => run11,
2515
+ terminal: () => terminal11
2516
+ });
2517
+ init_github();
2518
+ import { select as select8, input as promptInput4 } from "@inquirer/prompts";
2519
+ import ora9 from "ora";
2520
+ var definition11 = {
2521
+ type: "function",
2522
+ function: {
2523
+ name: "edit_task",
2524
+ description: "Edit the title and/or body of an existing task (GitHub Issue). Equivalent to /edit.",
2525
+ parameters: {
2526
+ type: "object",
2527
+ properties: {
2331
2528
  issue_number: { type: "number", description: "Issue number to edit." },
2332
2529
  title: { type: "string", description: "New title." },
2333
2530
  body: { type: "string", description: "New body/description." }
@@ -2347,7 +2544,7 @@ async function run11(input3, config) {
2347
2544
  }
2348
2545
  if (tasks.length === 0) return "No tasks found.";
2349
2546
  try {
2350
- issueNumber = await select9({
2547
+ issueNumber = await select8({
2351
2548
  message: "Select task to edit:",
2352
2549
  choices: tasks.map((t) => ({ name: `#${t.number} [${getStatus(t)}] ${t.title}`, value: t.number }))
2353
2550
  });
@@ -2378,7 +2575,7 @@ async function run11(input3, config) {
2378
2575
  if (title.trim() === issue.title && body.trim() === (issue.body ?? "")) {
2379
2576
  return "No changes made.";
2380
2577
  }
2381
- const spinner = ora10(`Updating #${issueNumber}\u2026`).start();
2578
+ const spinner = ora9(`Updating #${issueNumber}\u2026`).start();
2382
2579
  try {
2383
2580
  await editTask(config, issueNumber, title.trim() || issue.title, body.trim());
2384
2581
  spinner.stop();
@@ -2392,7 +2589,7 @@ async function execute11(input3, config) {
2392
2589
  const issueNumber = input3["issue_number"];
2393
2590
  const title = input3["title"];
2394
2591
  const body = input3["body"];
2395
- const spinner = ora10(`Updating #${issueNumber}\u2026`).start();
2592
+ const spinner = ora9(`Updating #${issueNumber}\u2026`).start();
2396
2593
  try {
2397
2594
  await editTask(config, issueNumber, title, body);
2398
2595
  spinner.stop();
@@ -2404,14 +2601,140 @@ async function execute11(input3, config) {
2404
2601
  }
2405
2602
  var terminal11 = true;
2406
2603
 
2604
+ // src/tools/wiki/index.ts
2605
+ var wiki_exports = {};
2606
+ __export(wiki_exports, {
2607
+ definition: () => definition12,
2608
+ execute: () => execute12,
2609
+ run: () => run12,
2610
+ terminal: () => terminal12
2611
+ });
2612
+ import ora10 from "ora";
2613
+ import chalk10 from "chalk";
2614
+ import { readFile as readFile2 } from "fs/promises";
2615
+ import { select as select9 } from "@inquirer/prompts";
2616
+ init_github();
2617
+ var WIKI_PATH = "TECHUNTER.md";
2618
+ var definition12 = {
2619
+ type: "function",
2620
+ function: {
2621
+ name: "update_wiki",
2622
+ description: "Generate or refresh the project overview document (TECHUNTER.md) by scanning the codebase. The document helps new team members understand what the project does, how it is architected, and how to start contributing. Equivalent to /wiki.",
2623
+ parameters: {
2624
+ type: "object",
2625
+ properties: {},
2626
+ required: []
2627
+ }
2628
+ }
2629
+ };
2630
+ async function readWikiContent(config) {
2631
+ try {
2632
+ return await readFile2(WIKI_PATH, "utf-8");
2633
+ } catch {
2634
+ }
2635
+ return getRepoFile(config, WIKI_PATH);
2636
+ }
2637
+ function printWiki(content) {
2638
+ const divider = chalk10.dim("\u2500".repeat(70));
2639
+ console.log("\n" + divider);
2640
+ console.log(chalk10.bold(" TECHUNTER.md"));
2641
+ console.log(divider);
2642
+ console.log(renderMarkdown(content));
2643
+ console.log(divider + "\n");
2644
+ }
2645
+ async function run12(_input, config) {
2646
+ const fetchSpinner = ora10("Checking for existing wiki\u2026").start();
2647
+ const existing = await readWikiContent(config).catch(() => null);
2648
+ fetchSpinner.stop();
2649
+ let action;
2650
+ try {
2651
+ action = await select9({
2652
+ message: "TECHUNTER.md \u2014 what would you like to do?",
2653
+ choices: [
2654
+ ...existing ? [{ name: "View current wiki", value: "view" }] : [],
2655
+ { name: existing ? "Regenerate & commit to repo" : "Generate & commit to repo", value: "generate" },
2656
+ { name: "Cancel", value: "cancel" }
2657
+ ]
2658
+ });
2659
+ } catch {
2660
+ return "Cancelled.";
2661
+ }
2662
+ if (action === "cancel") return "Cancelled.";
2663
+ if (action === "view") {
2664
+ printWiki(existing);
2665
+ return "Displayed TECHUNTER.md.";
2666
+ }
2667
+ const authSpinner = ora10("Checking permissions\u2026").start();
2668
+ let me;
2669
+ let allowed;
2670
+ try {
2671
+ me = await getAuthenticatedUser(config);
2672
+ allowed = await isCollaborator(config, me);
2673
+ authSpinner.stop();
2674
+ } catch (err) {
2675
+ authSpinner.stop();
2676
+ return `Error checking permissions: ${err.message}`;
2677
+ }
2678
+ if (!allowed) {
2679
+ return `Permission denied: only repository collaborators can update the project wiki.`;
2680
+ }
2681
+ const genSpinner = ora10("Analyzing project and generating overview\u2026").start();
2682
+ let content;
2683
+ try {
2684
+ content = await generateWiki(config);
2685
+ genSpinner.stop();
2686
+ } catch (err) {
2687
+ genSpinner.stop();
2688
+ return `Error generating wiki: ${err.message}`;
2689
+ }
2690
+ printWiki(content);
2691
+ let confirm;
2692
+ try {
2693
+ confirm = await select9({
2694
+ message: `Publish to repository as ${WIKI_PATH}?`,
2695
+ choices: [
2696
+ { name: "Yes, commit to repo", value: "publish" },
2697
+ { name: "Cancel", value: "cancel" }
2698
+ ]
2699
+ });
2700
+ } catch {
2701
+ return "Cancelled.";
2702
+ }
2703
+ if (confirm === "cancel") return "Cancelled.";
2704
+ const writeSpinner = ora10(`Writing ${WIKI_PATH}\u2026`).start();
2705
+ try {
2706
+ const url = await upsertRepoFile(config, WIKI_PATH, content, "docs: update TECHUNTER.md project overview");
2707
+ writeSpinner.succeed(`Written: ${url}`);
2708
+ console.log("");
2709
+ return `TECHUNTER.md updated \u2014 ${url}`;
2710
+ } catch (err) {
2711
+ writeSpinner.fail(`Failed: ${err.message}`);
2712
+ return `Error: ${err.message}`;
2713
+ }
2714
+ }
2715
+ async function execute12(_input, config) {
2716
+ const me = await getAuthenticatedUser(config);
2717
+ if (!await isCollaborator(config, me)) {
2718
+ return `Permission denied: only repository collaborators can update the project wiki.`;
2719
+ }
2720
+ const content = await generateWiki(config);
2721
+ try {
2722
+ const url = await upsertRepoFile(config, WIKI_PATH, content, "docs: update TECHUNTER.md project overview");
2723
+ return `TECHUNTER.md updated \u2014 ${url}`;
2724
+ } catch (err) {
2725
+ return `Error: ${err.message}`;
2726
+ }
2727
+ }
2728
+ var terminal12 = true;
2729
+
2407
2730
  // src/tools/list-tasks/index.ts
2408
2731
  var list_tasks_exports = {};
2409
2732
  __export(list_tasks_exports, {
2410
- definition: () => definition12,
2411
- execute: () => execute12
2733
+ definition: () => definition13,
2734
+ execute: () => execute13
2412
2735
  });
2413
2736
  init_github();
2414
- var definition12 = {
2737
+ var definition13 = {
2415
2738
  type: "function",
2416
2739
  function: {
2417
2740
  name: "list_tasks",
@@ -2423,7 +2746,7 @@ var definition12 = {
2423
2746
  }
2424
2747
  }
2425
2748
  };
2426
- async function execute12(_input, config) {
2749
+ async function execute13(_input, config) {
2427
2750
  const tasks = await listTasks(config);
2428
2751
  if (tasks.length === 0) return "No open tasks.";
2429
2752
  return tasks.map((t) => {
@@ -2436,11 +2759,11 @@ async function execute12(_input, config) {
2436
2759
  // src/tools/get-task/index.ts
2437
2760
  var get_task_exports = {};
2438
2761
  __export(get_task_exports, {
2439
- definition: () => definition13,
2440
- execute: () => execute13
2762
+ definition: () => definition14,
2763
+ execute: () => execute14
2441
2764
  });
2442
2765
  init_github();
2443
- var definition13 = {
2766
+ var definition14 = {
2444
2767
  type: "function",
2445
2768
  function: {
2446
2769
  name: "get_task",
@@ -2454,7 +2777,7 @@ var definition13 = {
2454
2777
  }
2455
2778
  }
2456
2779
  };
2457
- async function execute13(input3, config) {
2780
+ async function execute14(input3, config) {
2458
2781
  const issue = await getTask(config, input3["issue_number"]);
2459
2782
  const status = issue.labels.find((l) => l.startsWith("techunter:"))?.replace("techunter:", "") ?? "unknown";
2460
2783
  const assignee = issue.assignee ? `@${issue.assignee}` : "\u2014";
@@ -2471,12 +2794,12 @@ ${issue.body}`);
2471
2794
  // src/tools/get-comments/index.ts
2472
2795
  var get_comments_exports = {};
2473
2796
  __export(get_comments_exports, {
2474
- definition: () => definition14,
2475
- execute: () => execute14
2797
+ definition: () => definition15,
2798
+ execute: () => execute15
2476
2799
  });
2477
2800
  init_github();
2478
2801
  import ora11 from "ora";
2479
- var definition14 = {
2802
+ var definition15 = {
2480
2803
  type: "function",
2481
2804
  function: {
2482
2805
  name: "get_comments",
@@ -2491,7 +2814,7 @@ var definition14 = {
2491
2814
  }
2492
2815
  }
2493
2816
  };
2494
- async function execute14(input3, config) {
2817
+ async function execute15(input3, config) {
2495
2818
  const issueNumber = input3["issue_number"];
2496
2819
  const limit = input3["limit"] ?? 5;
2497
2820
  const spinner = ora11(`Loading comments for #${issueNumber}...`).start();
@@ -2513,11 +2836,11 @@ ${lines.join("\n\n")}`;
2513
2836
  // src/tools/get-diff/index.ts
2514
2837
  var get_diff_exports = {};
2515
2838
  __export(get_diff_exports, {
2516
- definition: () => definition15,
2517
- execute: () => execute15
2839
+ definition: () => definition16,
2840
+ execute: () => execute16
2518
2841
  });
2519
2842
  import ora12 from "ora";
2520
- var definition15 = {
2843
+ var definition16 = {
2521
2844
  type: "function",
2522
2845
  function: {
2523
2846
  name: "get_diff",
@@ -2525,7 +2848,7 @@ var definition15 = {
2525
2848
  parameters: { type: "object", properties: {}, required: [] }
2526
2849
  }
2527
2850
  };
2528
- async function execute15(_input, _config) {
2851
+ async function execute16(_input, _config) {
2529
2852
  const spinner = ora12("Reading git diff...").start();
2530
2853
  try {
2531
2854
  const diff = await getDiff();
@@ -2540,14 +2863,14 @@ async function execute15(_input, _config) {
2540
2863
  // src/tools/run-command/index.ts
2541
2864
  var run_command_exports = {};
2542
2865
  __export(run_command_exports, {
2543
- definition: () => definition16,
2544
- execute: () => execute16
2866
+ definition: () => definition17,
2867
+ execute: () => execute17
2545
2868
  });
2546
2869
  import { exec } from "child_process";
2547
2870
  import { promisify } from "util";
2548
2871
  import ora13 from "ora";
2549
2872
  var execAsync = promisify(exec);
2550
- var definition16 = {
2873
+ var definition17 = {
2551
2874
  type: "function",
2552
2875
  function: {
2553
2876
  name: "run_command",
@@ -2561,7 +2884,7 @@ var definition16 = {
2561
2884
  }
2562
2885
  }
2563
2886
  };
2564
- async function execute16(input3, _config) {
2887
+ async function execute17(input3, _config) {
2565
2888
  const command = input3["command"];
2566
2889
  const cwd = process.cwd();
2567
2890
  const spinner = ora13(`$ ${command}`).start();
@@ -2580,20 +2903,34 @@ ${out || e.message}`;
2580
2903
  }
2581
2904
  }
2582
2905
 
2583
- // src/tools/scan-project/index.ts
2584
- var scan_project_exports = {};
2585
- __export(scan_project_exports, {
2586
- definition: () => definition17,
2587
- execute: () => execute17
2906
+ // src/tools/list-files/index.ts
2907
+ var list_files_exports = {};
2908
+ __export(list_files_exports, {
2909
+ definition: () => definition18,
2910
+ execute: () => execute18
2588
2911
  });
2589
- import ora14 from "ora";
2590
-
2591
- // src/lib/project.ts
2592
- import { readFile as readFile2 } from "fs/promises";
2912
+ import { readFile as readFile3 } from "fs/promises";
2593
2913
  import { existsSync } from "fs";
2594
2914
  import path2 from "path";
2595
2915
  import { globby } from "globby";
2596
2916
  import ignore from "ignore";
2917
+ var definition18 = {
2918
+ type: "function",
2919
+ function: {
2920
+ name: "list_files",
2921
+ description: "List file paths in the project. Use this first to orient yourself before searching or reading.",
2922
+ parameters: {
2923
+ type: "object",
2924
+ properties: {
2925
+ glob: {
2926
+ type: "string",
2927
+ description: 'Glob pattern to filter results, e.g. "src/**/*.ts". Defaults to all text files.'
2928
+ }
2929
+ },
2930
+ required: []
2931
+ }
2932
+ }
2933
+ };
2597
2934
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2598
2935
  ".png",
2599
2936
  ".jpg",
@@ -2606,297 +2943,668 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2606
2943
  ".zip",
2607
2944
  ".tar",
2608
2945
  ".gz",
2609
- ".bz2",
2610
- ".rar",
2611
2946
  ".exe",
2612
2947
  ".dll",
2613
- ".so",
2614
- ".dylib",
2615
2948
  ".woff",
2616
2949
  ".woff2",
2617
2950
  ".ttf",
2618
- ".otf",
2619
- ".eot",
2620
2951
  ".mp3",
2621
2952
  ".mp4",
2622
- ".wav",
2623
- ".avi",
2624
- ".mov",
2625
2953
  ".db",
2626
2954
  ".sqlite",
2627
2955
  ".lock"
2628
2956
  ]);
2629
- var ALWAYS_READ = [
2630
- "README.md",
2631
- "README.txt",
2632
- "README",
2633
- "package.json",
2634
- "pyproject.toml",
2635
- "go.mod",
2636
- "Cargo.toml",
2637
- "tsconfig.json",
2638
- "vite.config.ts",
2639
- "vite.config.js",
2640
- "webpack.config.js",
2641
- "rollup.config.js",
2642
- ".env.example",
2643
- "docker-compose.yml",
2644
- "Dockerfile"
2645
- ];
2646
- var MAX_TOTAL_BYTES = 8e4;
2647
- var MAX_FILE_BYTES = 15e3;
2648
- async function buildIgnoreFilter(cwd) {
2957
+ async function execute18(input3, _config) {
2958
+ const glob = input3["glob"] ?? "**/*";
2959
+ const cwd = process.cwd();
2649
2960
  const ig = ignore();
2650
2961
  const gitignorePath = path2.join(cwd, ".gitignore");
2651
2962
  if (existsSync(gitignorePath)) {
2652
- const content = await readFile2(gitignorePath, "utf-8");
2653
- ig.add(content);
2654
- }
2655
- ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "*.pyc", "build", "coverage"]);
2656
- return ig;
2657
- }
2658
- async function safeReadFile(filePath, maxBytes = MAX_FILE_BYTES) {
2659
- try {
2660
- const content = await readFile2(filePath, "utf-8");
2661
- if (content.length > maxBytes) {
2662
- return content.slice(0, maxBytes) + `
2663
- ... (truncated at ${maxBytes} chars)`;
2664
- }
2665
- return content;
2666
- } catch {
2667
- return null;
2668
- }
2963
+ ig.add(await readFile3(gitignorePath, "utf-8"));
2964
+ }
2965
+ ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "build", "coverage"]);
2966
+ const files = await globby(glob, { cwd, dot: false, onlyFiles: true, gitignore: false });
2967
+ const filtered = files.filter((f) => !ig.ignores(f) && !BINARY_EXTENSIONS.has(path2.extname(f).toLowerCase()));
2968
+ if (filtered.length === 0) return `No files matched: ${glob}`;
2969
+ return `${filtered.length} file(s):
2970
+ ${filtered.join("\n")}`;
2669
2971
  }
2670
- function isBinaryFile(filePath) {
2671
- const ext = path2.extname(filePath).toLowerCase();
2672
- return BINARY_EXTENSIONS.has(ext);
2673
- }
2674
- function buildFileTree(files) {
2675
- const tree = {};
2676
- for (const file of files) {
2677
- const dir = path2.dirname(file);
2678
- if (!tree[dir]) tree[dir] = [];
2679
- tree[dir].push(path2.basename(file));
2680
- }
2681
- const lines = [];
2682
- const rootFiles = tree["."] ?? [];
2683
- for (const f of rootFiles) lines.push(f);
2684
- const dirs = Object.keys(tree).filter((d) => d !== ".").sort();
2685
- for (const dir of dirs) {
2686
- lines.push(`${dir}/`);
2687
- for (const f of tree[dir]) {
2688
- lines.push(` ${f}`);
2689
- }
2690
- }
2691
- return lines.join("\n");
2692
- }
2693
- function scoreRelevance(filePath, keywords) {
2694
- const lower = filePath.toLowerCase();
2695
- let score = 0;
2696
- for (const kw of keywords) {
2697
- if (lower.includes(kw)) score += 1;
2698
- }
2699
- return score;
2700
- }
2701
- async function buildProjectContext(cwd, issueTitle, issueBody) {
2702
- const ig = await buildIgnoreFilter(cwd);
2703
- const allFiles = await globby("**/*", {
2704
- cwd,
2705
- gitignore: false,
2706
- // We handle ignore ourselves
2707
- dot: false,
2708
- onlyFiles: true
2709
- });
2710
- const filtered = allFiles.filter((f) => !ig.ignores(f) && !isBinaryFile(f));
2711
- const fileTree = buildFileTree(filtered);
2712
- const issueText = `${issueTitle} ${issueBody}`.toLowerCase();
2713
- const keywords = issueText.replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3);
2714
- const keyFiles = {};
2715
- let totalBytes = 0;
2716
- for (const always of ALWAYS_READ) {
2717
- if (totalBytes >= MAX_TOTAL_BYTES) break;
2718
- const fullPath = path2.join(cwd, always);
2719
- if (!existsSync(fullPath)) continue;
2720
- const content = await safeReadFile(fullPath);
2721
- if (content !== null) {
2722
- keyFiles[always] = content;
2723
- totalBytes += content.length;
2724
- }
2725
- }
2726
- const scored = filtered.filter((f) => !ALWAYS_READ.includes(f) && !ALWAYS_READ.includes(path2.basename(f))).map((f) => ({ file: f, score: scoreRelevance(f, keywords) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 10);
2727
- for (const { file } of scored) {
2728
- if (totalBytes >= MAX_TOTAL_BYTES) break;
2729
- const fullPath = path2.join(cwd, file);
2730
- const content = await safeReadFile(fullPath);
2731
- if (content !== null) {
2732
- keyFiles[file] = content;
2733
- totalBytes += content.length;
2734
- }
2735
- }
2736
- return { fileTree, keyFiles };
2737
- }
2738
-
2739
- // src/tools/scan-project/index.ts
2740
- var definition17 = {
2972
+
2973
+ // src/tools/grep-code/index.ts
2974
+ var grep_code_exports = {};
2975
+ __export(grep_code_exports, {
2976
+ definition: () => definition19,
2977
+ execute: () => execute19
2978
+ });
2979
+ import { readFile as readFile4 } from "fs/promises";
2980
+ import { existsSync as existsSync2 } from "fs";
2981
+ import path3 from "path";
2982
+ import { globby as globby2 } from "globby";
2983
+ import ignore2 from "ignore";
2984
+ var definition19 = {
2741
2985
  type: "function",
2742
2986
  function: {
2743
- name: "scan_project",
2744
- description: "Scan the current project directory: returns the file tree and contents of the most relevant files. Call this when creating a new task to understand the codebase before writing the task body and guide.",
2987
+ name: "grep_code",
2988
+ description: "Search for a pattern across files, or read a specific line range from a file.\n- Search mode: provide `pattern` \u2014 returns matching lines with context.\n- Read-range mode: provide `file_glob` (single file) + `start_line` + `end_line`, no `pattern` \u2014 read an exact section. Use after grep has given you line numbers.",
2745
2989
  parameters: {
2746
2990
  type: "object",
2747
2991
  properties: {
2748
- focus: {
2992
+ pattern: {
2993
+ type: "string",
2994
+ description: "Regex or plain text to search for (case-insensitive). Omit for read-range mode."
2995
+ },
2996
+ file_glob: {
2749
2997
  type: "string",
2750
- description: "Keywords describing the task. Used to prioritise which files to read."
2998
+ description: 'Glob to restrict which files to search or read, e.g. "src/**/*.ts" or "src/lib/agent.ts". Defaults to all text files.'
2999
+ },
3000
+ context_lines: {
3001
+ type: "number",
3002
+ description: "Lines of context before/after each match (search mode only). Default: 2."
3003
+ },
3004
+ max_results: {
3005
+ type: "number",
3006
+ description: "Max matches to return (search mode only). Default: 50."
3007
+ },
3008
+ start_line: {
3009
+ type: "number",
3010
+ description: "First line to read, 1-based (read-range mode). Requires file_glob pointing to a single file."
3011
+ },
3012
+ end_line: {
3013
+ type: "number",
3014
+ description: "Last line to read, 1-based (read-range mode)."
2751
3015
  }
2752
3016
  },
2753
3017
  required: []
2754
3018
  }
2755
3019
  }
2756
3020
  };
2757
- async function execute17(input3, _config) {
2758
- const focus = input3["focus"] ?? "";
2759
- const spinner = ora14("Scanning project...").start();
2760
- try {
2761
- const cwd = process.cwd();
2762
- const context = await buildProjectContext(cwd, focus, "");
2763
- spinner.stop();
2764
- const fileCount = context.fileTree.split("\n").filter(Boolean).length;
2765
- const readCount = Object.keys(context.keyFiles).length;
2766
- const totalBytes = Object.values(context.keyFiles).reduce((s, c) => s + c.length, 0);
2767
- const summary = `Scanned ${fileCount} files \xB7 ${readCount} read \xB7 ${(totalBytes / 1024).toFixed(1)} KB`;
2768
- const parts = [summary, `## File tree
3021
+ async function buildIgnore(cwd) {
3022
+ const ig = ignore2();
3023
+ const gitignorePath = path3.join(cwd, ".gitignore");
3024
+ if (existsSync2(gitignorePath)) {
3025
+ ig.add(await readFile4(gitignorePath, "utf-8"));
3026
+ }
3027
+ ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "build", "coverage"]);
3028
+ return ig;
3029
+ }
3030
+ var BINARY_EXTENSIONS2 = /* @__PURE__ */ new Set([
3031
+ ".png",
3032
+ ".jpg",
3033
+ ".jpeg",
3034
+ ".gif",
3035
+ ".svg",
3036
+ ".ico",
3037
+ ".webp",
3038
+ ".pdf",
3039
+ ".zip",
3040
+ ".tar",
3041
+ ".gz",
3042
+ ".exe",
3043
+ ".dll",
3044
+ ".woff",
3045
+ ".woff2",
3046
+ ".ttf",
3047
+ ".mp3",
3048
+ ".mp4",
3049
+ ".db",
3050
+ ".sqlite",
3051
+ ".lock"
3052
+ ]);
3053
+ function isText(f) {
3054
+ return !BINARY_EXTENSIONS2.has(path3.extname(f).toLowerCase());
3055
+ }
3056
+ var MAX_RANGE_LINES = 300;
3057
+ async function execute19(input3, _config) {
3058
+ const pattern = input3["pattern"] ?? "";
3059
+ const fileGlob = input3["file_glob"] ?? "**/*";
3060
+ const contextLines = Math.min(input3["context_lines"] ?? 2, 5);
3061
+ const maxResults = Math.min(input3["max_results"] ?? 50, 200);
3062
+ const startLine = input3["start_line"];
3063
+ const endLine = input3["end_line"];
3064
+ const cwd = process.cwd();
3065
+ if (!pattern && startLine != null && endLine != null) {
3066
+ const files = await globby2(fileGlob, { cwd, dot: false, onlyFiles: true, gitignore: false });
3067
+ if (files.length === 0) return `No file matched: ${fileGlob}`;
3068
+ if (files.length > 1) return `file_glob matched ${files.length} files \u2014 narrow it to a single file for read-range mode.`;
3069
+ const raw = await readFile4(path3.join(cwd, files[0]), "utf-8");
3070
+ const lines = raw.split("\n");
3071
+ const total = lines.length;
3072
+ const from = Math.max(1, startLine);
3073
+ const to = Math.min(total, endLine, from + MAX_RANGE_LINES - 1);
3074
+ const numbered = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(5)}: ${l}`).join("\n");
3075
+ const truncNote = to < Math.min(total, endLine) ? `
3076
+ \u2026 (use start_line=${to + 1} to continue)` : "";
3077
+ return `${files[0]} \u2014 lines ${from}\u2013${to} of ${total}:
2769
3078
  \`\`\`
2770
- ${context.fileTree}
2771
- \`\`\``];
2772
- for (const [filePath, content] of Object.entries(context.keyFiles)) {
2773
- parts.push(`## ${filePath}
3079
+ ${numbered}
3080
+ \`\`\`${truncNote}`;
3081
+ }
3082
+ if (!pattern) return "Provide a `pattern` to search, or `start_line` + `end_line` for read-range mode. Use list_files to browse file paths.";
3083
+ const ig = await buildIgnore(cwd);
3084
+ let regex;
3085
+ try {
3086
+ regex = new RegExp(pattern, "i");
3087
+ } catch {
3088
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
3089
+ }
3090
+ const allFiles = await globby2(fileGlob, { cwd, dot: false, onlyFiles: true, gitignore: false });
3091
+ const filtered = allFiles.filter((f) => !ig.ignores(f) && isText(f));
3092
+ const matches = [];
3093
+ let totalMatches = 0;
3094
+ for (const file of filtered) {
3095
+ if (totalMatches >= maxResults) break;
3096
+ let content;
3097
+ try {
3098
+ content = await readFile4(path3.join(cwd, file), "utf-8");
3099
+ } catch {
3100
+ continue;
3101
+ }
3102
+ const lines = content.split("\n");
3103
+ const hitLines = [];
3104
+ for (let i = 0; i < lines.length; i++) {
3105
+ if (regex.test(lines[i])) hitLines.push(i);
3106
+ }
3107
+ if (hitLines.length === 0) continue;
3108
+ const ranges = [];
3109
+ for (const hit of hitLines) {
3110
+ const s = Math.max(0, hit - contextLines);
3111
+ const e = Math.min(lines.length - 1, hit + contextLines);
3112
+ if (ranges.length > 0 && s <= ranges[ranges.length - 1][1] + 1) {
3113
+ ranges[ranges.length - 1][1] = e;
3114
+ } else {
3115
+ ranges.push([s, e]);
3116
+ }
3117
+ }
3118
+ const snippets = [];
3119
+ for (const [s, e] of ranges) {
3120
+ if (totalMatches >= maxResults) break;
3121
+ snippets.push(
3122
+ lines.slice(s, e + 1).map((l, i) => {
3123
+ const n = s + i + 1;
3124
+ return `${regex.test(l) ? ">" : " "} ${String(n).padStart(4)}: ${l}`;
3125
+ }).join("\n")
3126
+ );
3127
+ totalMatches += hitLines.filter((h) => h >= s && h <= e).length;
3128
+ }
3129
+ if (snippets.length > 0) {
3130
+ matches.push(`## ${file}
2774
3131
  \`\`\`
2775
- ${content}
3132
+ ${snippets.join("\n---\n")}
2776
3133
  \`\`\``);
2777
3134
  }
2778
- return parts.join("\n\n");
2779
- } catch (err) {
2780
- spinner.stop();
2781
- throw err;
2782
3135
  }
3136
+ if (matches.length === 0) return `No matches found for: ${pattern}`;
3137
+ const header = `Found matches in ${matches.length} file(s) (${totalMatches} match${totalMatches === 1 ? "" : "es"})${totalMatches >= maxResults ? " \u2014 limit reached" : ""}:`;
3138
+ return [header, ...matches].join("\n\n");
2783
3139
  }
2784
3140
 
2785
- // src/tools/read-file/index.ts
2786
- var read_file_exports = {};
2787
- __export(read_file_exports, {
2788
- definition: () => definition18,
2789
- execute: () => execute18
3141
+ // src/tools/ask-user/index.ts
3142
+ var ask_user_exports = {};
3143
+ __export(ask_user_exports, {
3144
+ definition: () => definition20,
3145
+ execute: () => execute20
2790
3146
  });
2791
- import { readFile as readFile3 } from "fs/promises";
2792
- import path3 from "path";
2793
- var definition18 = {
3147
+ import chalk11 from "chalk";
3148
+ import { select as select10, input as promptInput5 } from "@inquirer/prompts";
3149
+ var definition20 = {
2794
3150
  type: "function",
2795
3151
  function: {
2796
- name: "read_file",
2797
- description: "Read the full contents of a specific file in the project.",
3152
+ name: "ask_user",
3153
+ description: "Ask the user to clarify something ambiguous \u2014 scope, expected behaviour, edge cases, or business decisions. Do NOT ask about technical implementation choices. Use at most 3 times per task.",
2798
3154
  parameters: {
2799
3155
  type: "object",
2800
3156
  properties: {
2801
- path: { type: "string", description: "File path relative to the project root" }
3157
+ question: { type: "string", description: "The question to ask the user" },
3158
+ options: {
3159
+ type: "array",
3160
+ items: { type: "string" },
3161
+ description: "2\u20134 concrete answer choices"
3162
+ }
2802
3163
  },
2803
- required: ["path"]
3164
+ required: ["question", "options"]
3165
+ }
3166
+ }
3167
+ };
3168
+ async function execute20(input3, _config) {
3169
+ const question = input3["question"];
3170
+ const options = input3["options"];
3171
+ const OTHER = "__other__";
3172
+ console.log("");
3173
+ console.log(chalk11.dim(" \u250C\u2500 Agent question " + "\u2500".repeat(51)));
3174
+ console.log(chalk11.dim(" \u2502"));
3175
+ for (const line of question.split("\n")) {
3176
+ console.log(chalk11.dim(" \u2502 ") + line);
3177
+ }
3178
+ console.log(chalk11.dim(" \u2514" + "\u2500".repeat(67)));
3179
+ let answer;
3180
+ try {
3181
+ const chosen = await select10({
3182
+ message: " ",
3183
+ choices: [
3184
+ ...options.map((o) => ({ name: o, value: o })),
3185
+ { name: chalk11.dim("Other (describe below)"), value: OTHER }
3186
+ ]
3187
+ });
3188
+ answer = chosen === OTHER ? await promptInput5({ message: "Your answer:" }) : chosen;
3189
+ } catch {
3190
+ answer = "User skipped this question \u2014 use your best judgement.";
3191
+ }
3192
+ console.log("");
3193
+ return answer;
3194
+ }
3195
+
3196
+ // src/tools/registry.ts
3197
+ var toolModules = [
3198
+ // Command tools
3199
+ pick_exports,
3200
+ new_task_exports,
3201
+ close_exports,
3202
+ submit_exports,
3203
+ my_status_exports,
3204
+ review_exports,
3205
+ refresh_exports,
3206
+ open_code_exports,
3207
+ reject_exports,
3208
+ accept_exports,
3209
+ edit_task_exports,
3210
+ wiki_exports,
3211
+ // Low-level tools
3212
+ list_tasks_exports,
3213
+ get_task_exports,
3214
+ get_comments_exports,
3215
+ get_diff_exports,
3216
+ run_command_exports,
3217
+ list_files_exports,
3218
+ grep_code_exports,
3219
+ ask_user_exports
3220
+ ];
3221
+
3222
+ // src/lib/agent-ui.ts
3223
+ import chalk12 from "chalk";
3224
+ function formatInput(input3) {
3225
+ return Object.entries(input3).map(([k, v]) => {
3226
+ if (typeof v === "number") return `${k}=${v}`;
3227
+ if (typeof v === "string") {
3228
+ if (k === "body" || v.length > 50) return `${k}=[${v.length} chars]`;
3229
+ return `${k}="${v}"`;
3230
+ }
3231
+ return `${k}=${JSON.stringify(v)}`;
3232
+ }).join(" ");
3233
+ }
3234
+ function summarize(result) {
3235
+ const first = result.split("\n").find((l) => l.trim()) ?? result;
3236
+ return first.length > 100 ? first.slice(0, 97) + "..." : first;
3237
+ }
3238
+ function printToolCall(name, input3) {
3239
+ const params = formatInput(input3);
3240
+ console.log(` ${chalk12.cyan("\u2192")} ${chalk12.bold(name)}${params ? " " + chalk12.dim(params) : ""}`);
3241
+ }
3242
+ function printToolResult(result) {
3243
+ const ok = !result.startsWith("Error:");
3244
+ const icon = ok ? chalk12.green("\u2713") : chalk12.red("\u2717");
3245
+ console.log(` ${icon} ${chalk12.dim(summarize(result))}`);
3246
+ }
3247
+
3248
+ // src/lib/sub-agent.ts
3249
+ async function runSubAgentLoop(config, systemPrompt, userMessage, toolNames) {
3250
+ const client = createClient(config);
3251
+ const selected = toolModules.filter((m) => toolNames.includes(m.definition.function.name));
3252
+ const tools2 = selected.map((m) => m.definition);
3253
+ const messages = [
3254
+ { role: "system", content: systemPrompt },
3255
+ { role: "user", content: userMessage }
3256
+ ];
3257
+ const MAX_ITERATIONS = 100;
3258
+ let iterations = 0;
3259
+ for (; ; ) {
3260
+ if (++iterations > MAX_ITERATIONS) {
3261
+ throw new Error(`Sub-agent exceeded ${MAX_ITERATIONS} iterations without finishing.`);
3262
+ }
3263
+ const res = await client.chat.completions.create({ model: getModel(config), tools: tools2, messages });
3264
+ const choice = res.choices[0];
3265
+ messages.push({
3266
+ role: "assistant",
3267
+ content: choice.message.content ?? null,
3268
+ ...choice.message.tool_calls ? { tool_calls: choice.message.tool_calls } : {}
3269
+ });
3270
+ if (choice.finish_reason === "stop") {
3271
+ return choice.message.content ?? "";
3272
+ }
3273
+ if (choice.finish_reason === "tool_calls") {
3274
+ for (const tc of choice.message.tool_calls ?? []) {
3275
+ let input3;
3276
+ try {
3277
+ input3 = JSON.parse(tc.function.arguments);
3278
+ } catch {
3279
+ input3 = {};
3280
+ }
3281
+ printToolCall(tc.function.name, input3);
3282
+ const mod = selected.find((m) => m.definition.function.name === tc.function.name);
3283
+ const result = mod ? await mod.execute(input3, config) : `Unknown tool: ${tc.function.name}`;
3284
+ printToolResult(result);
3285
+ messages.push({ role: "tool", tool_call_id: tc.id, content: result });
3286
+ }
3287
+ }
3288
+ }
3289
+ }
3290
+
3291
+ // src/tools/wiki/prompts.ts
3292
+ var WIKI_FORMAT = `
3293
+ The document you produce must be valid Markdown with these exact sections:
3294
+
3295
+ # [Project Name]
3296
+
3297
+ > One-sentence description of what this project does.
3298
+
3299
+ ## What Is This?
3300
+
3301
+ 2-4 paragraphs covering:
3302
+ - The problem this project solves
3303
+ - Who uses it and in what context
3304
+ - Core capabilities / key features
3305
+
3306
+ ## Quick Start
3307
+
3308
+ Numbered steps for a brand-new developer to install, configure, and run the project for the first time.
3309
+
3310
+ ## Architecture
3311
+
3312
+ High-level explanation of how the system is structured:
3313
+ - Key components / layers and their responsibilities
3314
+ - Request or data flow (prose or ASCII diagram)
3315
+ - Noteworthy design decisions
3316
+
3317
+ ## Key Files
3318
+
3319
+ | File / Directory | Purpose |
3320
+ |---|---|
3321
+ | ... | ... |
3322
+
3323
+ (List the 8-15 most important files.)
3324
+
3325
+ ## Development Workflow
3326
+
3327
+ Common day-to-day tasks a contributor will need:
3328
+ - How to build / run locally
3329
+ - How to add a new feature (brief steps)
3330
+ - Any testing or linting commands
3331
+
3332
+ ---
3333
+ *Maintained by Techunter \u2014 run \`tch wiki\` to regenerate*
3334
+ `;
3335
+
3336
+ // src/tools/wiki/wiki-generator.ts
3337
+ async function generateWiki(config) {
3338
+ return runSubAgentLoop(
3339
+ config,
3340
+ "You are a senior engineer writing a project overview document for new team members. Use list_files to understand the project structure, then grep_code and run_command to read key files (e.g. package.json, README, entry points, config files). Be concrete and specific \u2014 reference real file names, commands, and concepts from this codebase. Avoid vague filler. When you have enough context, write the document.\n\n" + WIKI_FORMAT,
3341
+ "Analyze this project thoroughly and produce a comprehensive TECHUNTER.md overview document for new team members.",
3342
+ ["list_files", "grep_code", "run_command"]
3343
+ );
3344
+ }
3345
+
3346
+ // src/commands/init.ts
3347
+ async function getGitHubTokenViaPAT() {
3348
+ console.log(chalk13.dim("\n Create a token at: https://github.com/settings/tokens/new"));
3349
+ console.log(chalk13.dim(" Required scopes: repo, read:user\n"));
3350
+ const token = await password({
3351
+ message: "GitHub Personal Access Token:",
3352
+ mask: "*"
3353
+ });
3354
+ return { token: token.trim() };
3355
+ }
3356
+ var OAUTH_CLIENT_ID = "Ov23liW4zJ4r2RdZOsCJ";
3357
+ async function getGitHubTokenViaDeviceFlow() {
3358
+ let verificationUri = "";
3359
+ let userCode = "";
3360
+ const auth = createOAuthDeviceAuth({
3361
+ clientType: "oauth-app",
3362
+ clientId: OAUTH_CLIENT_ID,
3363
+ scopes: ["repo"],
3364
+ onVerification(verification) {
3365
+ verificationUri = verification.verification_uri;
3366
+ userCode = verification.user_code;
3367
+ console.log("");
3368
+ console.log(chalk13.bold(" 1. Open this URL in your browser:"));
3369
+ console.log(" " + chalk13.cyan(verificationUri));
3370
+ console.log("");
3371
+ console.log(chalk13.bold(" 2. Enter this code:"));
3372
+ console.log(" " + chalk13.yellow.bold(userCode));
3373
+ console.log("");
3374
+ open2(verificationUri).catch(() => {
3375
+ });
2804
3376
  }
2805
- }
2806
- };
2807
- async function execute18(input3, _config) {
2808
- const filePath = input3["path"];
3377
+ });
3378
+ const spinner = ora14("Waiting for authorization in browser...").start();
3379
+ let token;
2809
3380
  try {
2810
- const fullPath = path3.join(process.cwd(), filePath);
2811
- const content = await readFile3(fullPath, "utf-8");
2812
- return content.length > 15e3 ? content.slice(0, 15e3) + "\n\n... (truncated)" : content;
3381
+ const result = await auth({ type: "oauth" });
3382
+ token = result.token;
3383
+ spinner.succeed("Authorized!");
2813
3384
  } catch (err) {
2814
- return `Error reading file: ${err.message}`;
3385
+ spinner.fail("Authorization failed");
3386
+ throw err;
2815
3387
  }
3388
+ return { token, clientId: OAUTH_CLIENT_ID };
2816
3389
  }
2817
-
2818
- // src/tools/ask-user/index.ts
2819
- var ask_user_exports = {};
2820
- __export(ask_user_exports, {
2821
- definition: () => definition19,
2822
- execute: () => execute19
2823
- });
2824
- import chalk12 from "chalk";
2825
- import { select as select10, input as promptInput5 } from "@inquirer/prompts";
2826
- var definition19 = {
2827
- type: "function",
2828
- function: {
2829
- name: "ask_user",
2830
- description: "Ask the user to clarify something ambiguous \u2014 scope, expected behaviour, edge cases, or business decisions. Do NOT ask about technical implementation choices. Use at most 3 times per task.",
2831
- parameters: {
2832
- type: "object",
2833
- properties: {
2834
- question: { type: "string", description: "The question to ask the user" },
2835
- options: {
2836
- type: "array",
2837
- items: { type: "string" },
2838
- description: "2\u20134 concrete answer choices"
2839
- }
2840
- },
2841
- required: ["question", "options"]
3390
+ async function initCommand() {
3391
+ console.log(chalk13.bold.cyan("\nTechunter \u2014 Initial Setup\n"));
3392
+ let detectedOwner = "";
3393
+ let detectedRepo = "";
3394
+ const remoteUrl = await getRemoteUrl();
3395
+ if (remoteUrl) {
3396
+ const parsed = parseOwnerRepo(remoteUrl);
3397
+ if (parsed) {
3398
+ detectedOwner = parsed.owner;
3399
+ detectedRepo = parsed.repo;
3400
+ console.log(chalk13.dim(`Detected GitHub repo: ${detectedOwner}/${detectedRepo}
3401
+ `));
2842
3402
  }
2843
3403
  }
2844
- };
2845
- async function execute19(input3, _config) {
2846
- const question = input3["question"];
2847
- const options = input3["options"];
2848
- const OTHER = "__other__";
2849
- console.log("");
2850
- console.log(chalk12.dim(" \u250C\u2500 Agent question " + "\u2500".repeat(51)));
2851
- console.log(chalk12.dim(" \u2502"));
2852
- for (const line of question.split("\n")) {
2853
- console.log(chalk12.dim(" \u2502 ") + line);
3404
+ const authMethod = await select11({
3405
+ message: "How would you like to authenticate with GitHub?",
3406
+ choices: [
3407
+ {
3408
+ name: "Browser login (OAuth) \u2014 open a URL and click Authorize",
3409
+ value: "device"
3410
+ },
3411
+ {
3412
+ name: "Personal Access Token (PAT) \u2014 paste a token from github.com/settings/tokens",
3413
+ value: "pat"
3414
+ }
3415
+ ]
3416
+ });
3417
+ let githubToken;
3418
+ let githubClientId;
3419
+ if (authMethod === "device") {
3420
+ const result = await getGitHubTokenViaDeviceFlow();
3421
+ githubToken = result.token;
3422
+ githubClientId = result.clientId;
3423
+ } else {
3424
+ const result = await getGitHubTokenViaPAT();
3425
+ githubToken = result.token;
2854
3426
  }
2855
- console.log(chalk12.dim(" \u2514" + "\u2500".repeat(67)));
2856
- let answer;
3427
+ const providerChoice = await select11({
3428
+ message: "AI provider:",
3429
+ choices: [
3430
+ { name: `OpenRouter (recommended) ${chalk13.dim(`${DEFAULT_BASE_URL} \xB7 ${DEFAULT_MODEL}`)}`, value: "openrouter" },
3431
+ { name: "Custom (specify base URL and model)", value: "custom" }
3432
+ ]
3433
+ });
3434
+ let aiBaseUrl;
3435
+ let aiModel;
3436
+ if (providerChoice === "custom") {
3437
+ aiBaseUrl = (await input({ message: "API base URL:", default: DEFAULT_BASE_URL })).trim();
3438
+ aiModel = (await input({ message: "Model name:", default: DEFAULT_MODEL })).trim();
3439
+ }
3440
+ const apiKeyHint = providerChoice === "openrouter" ? chalk13.dim(" Get a key at: https://openrouter.ai/settings/keys\n") : chalk13.dim(" API key for your provider\n");
3441
+ console.log(apiKeyHint);
3442
+ const aiApiKey = await password({
3443
+ message: "API Key:",
3444
+ mask: "*"
3445
+ });
3446
+ let owner = detectedOwner;
3447
+ let repo = detectedRepo;
3448
+ if (!owner || !repo) {
3449
+ owner = await input({
3450
+ message: "GitHub repo owner (user or org):",
3451
+ required: true
3452
+ });
3453
+ repo = await input({
3454
+ message: "GitHub repo name:",
3455
+ required: true
3456
+ });
3457
+ }
3458
+ const config = {
3459
+ githubToken,
3460
+ githubClientId,
3461
+ aiApiKey: aiApiKey.trim(),
3462
+ ...aiBaseUrl ? { aiBaseUrl } : {},
3463
+ ...aiModel ? { aiModel } : {},
3464
+ github: {
3465
+ owner: owner.trim(),
3466
+ repo: repo.trim()
3467
+ }
3468
+ };
3469
+ setConfig(config);
3470
+ const spinner = ora14("Setting up GitHub labels...").start();
2857
3471
  try {
2858
- const chosen = await select10({
2859
- message: " ",
3472
+ await ensureLabels(config);
3473
+ spinner.succeed("GitHub labels created");
3474
+ } catch (err) {
3475
+ spinner.fail("Failed to create labels (check token permissions)");
3476
+ console.error(chalk13.red(String(err)));
3477
+ }
3478
+ console.log(chalk13.green("\nSetup complete!"));
3479
+ console.log(chalk13.dim(`Config saved to: ${getConfigPath()}
3480
+ `));
3481
+ let genWiki = false;
3482
+ try {
3483
+ genWiki = await select11({
3484
+ message: "Generate TECHUNTER.md project overview for new team members?",
2860
3485
  choices: [
2861
- ...options.map((o) => ({ name: o, value: o })),
2862
- { name: chalk12.dim("Other (describe below)"), value: OTHER }
3486
+ { name: "Yes, generate now", value: true },
3487
+ { name: "No, skip (run /wiki later)", value: false }
2863
3488
  ]
2864
3489
  });
2865
- answer = chosen === OTHER ? await promptInput5({ message: "Your answer:" }) : chosen;
2866
3490
  } catch {
2867
- answer = "User skipped this question \u2014 use your best judgement.";
2868
3491
  }
2869
- console.log("");
2870
- return answer;
3492
+ if (genWiki) {
3493
+ const wikiSpinner = ora14("Analyzing project and generating TECHUNTER.md\u2026").start();
3494
+ try {
3495
+ const content = await generateWiki(config);
3496
+ await upsertRepoFile(config, "TECHUNTER.md", content, "docs: add TECHUNTER.md project overview");
3497
+ wikiSpinner.succeed("TECHUNTER.md created");
3498
+ } catch (err) {
3499
+ wikiSpinner.fail(`Could not generate wiki: ${err.message}`);
3500
+ }
3501
+ console.log("");
3502
+ }
2871
3503
  }
2872
3504
 
2873
- // src/tools/registry.ts
2874
- var toolModules = [
2875
- // Command tools
2876
- pick_exports,
2877
- new_task_exports,
2878
- close_exports,
2879
- submit_exports,
2880
- my_status_exports,
2881
- review_exports,
2882
- refresh_exports,
2883
- open_code_exports,
2884
- reject_exports,
2885
- accept_exports,
2886
- edit_task_exports,
2887
- // Low-level tools
2888
- list_tasks_exports,
2889
- get_task_exports,
2890
- get_comments_exports,
2891
- get_diff_exports,
2892
- run_command_exports,
2893
- scan_project_exports,
2894
- read_file_exports,
2895
- ask_user_exports
2896
- ];
3505
+ // src/commands/config.ts
3506
+ import { input as input2, password as password2, select as select12 } from "@inquirer/prompts";
3507
+ import chalk14 from "chalk";
3508
+ async function configCommand() {
3509
+ let config;
3510
+ try {
3511
+ config = getConfig();
3512
+ } catch {
3513
+ console.error(chalk14.red("No config found. Run `tch init` first."));
3514
+ process.exit(1);
3515
+ }
3516
+ console.log(chalk14.bold.cyan("\nTechunter \u2014 Settings\n"));
3517
+ console.log(chalk14.dim(`Config file: ${getConfigPath()}
3518
+ `));
3519
+ const currentBaseUrl = config.aiBaseUrl ?? DEFAULT_BASE_URL;
3520
+ const currentModel = config.aiModel ?? DEFAULT_MODEL;
3521
+ const currentBaseBranch = config.baseBranch ?? "main";
3522
+ const field = await select12({
3523
+ message: "Which setting to change?",
3524
+ choices: [
3525
+ { name: `GitHub repo ${chalk14.dim(`${config.github.owner}/${config.github.repo}`)}`, value: "repo" },
3526
+ { name: `Base branch ${chalk14.dim(currentBaseBranch)}`, value: "baseBranch" },
3527
+ { name: `AI base URL ${chalk14.dim(currentBaseUrl)}`, value: "aiBaseUrl" },
3528
+ { name: `AI model ${chalk14.dim(currentModel)}`, value: "aiModel" },
3529
+ { name: `AI API Key ${chalk14.dim("(hidden)")}`, value: "aiApiKey" },
3530
+ { name: `GitHub Token ${chalk14.dim("(hidden)")}`, value: "githubToken" },
3531
+ { name: "Cancel", value: "cancel" }
3532
+ ]
3533
+ });
3534
+ if (field === "cancel") return;
3535
+ if (field === "baseBranch") {
3536
+ const val = await input2({ message: "Base branch name:", default: currentBaseBranch });
3537
+ if (val.trim()) {
3538
+ setConfig({ baseBranch: val.trim() });
3539
+ console.log(chalk14.green(`
3540
+ Base branch set to: ${val.trim()}
3541
+ `));
3542
+ }
3543
+ } else if (field === "repo") {
3544
+ const owner = await input2({ message: "GitHub repo owner:", default: config.github.owner });
3545
+ const repo = await input2({ message: "GitHub repo name:", default: config.github.repo });
3546
+ setConfig({ github: { ...config.github, owner: owner.trim(), repo: repo.trim() } });
3547
+ console.log(chalk14.green(`
3548
+ Repo set to: ${owner.trim()}/${repo.trim()}
3549
+ `));
3550
+ } else if (field === "aiBaseUrl") {
3551
+ const val = await input2({ message: "AI base URL:", default: currentBaseUrl });
3552
+ if (val.trim()) {
3553
+ setConfig({ aiBaseUrl: val.trim() });
3554
+ console.log(chalk14.green(`
3555
+ AI base URL set to: ${val.trim()}
3556
+ `));
3557
+ }
3558
+ } else if (field === "aiModel") {
3559
+ const val = await input2({ message: "AI model name:", default: currentModel });
3560
+ if (val.trim()) {
3561
+ setConfig({ aiModel: val.trim() });
3562
+ console.log(chalk14.green(`
3563
+ AI model set to: ${val.trim()}
3564
+ `));
3565
+ }
3566
+ } else if (field === "aiApiKey") {
3567
+ const val = await password2({ message: "New AI API Key:", mask: "*" });
3568
+ if (val.trim()) {
3569
+ setConfig({ aiApiKey: val.trim() });
3570
+ console.log(chalk14.green("\nAI API Key updated.\n"));
3571
+ }
3572
+ } else if (field === "githubToken") {
3573
+ const val = await password2({ message: "New GitHub Token:", mask: "*" });
3574
+ if (val.trim()) {
3575
+ setConfig({ githubToken: val.trim() });
3576
+ console.log(chalk14.green("\nGitHub Token updated.\n"));
3577
+ }
3578
+ }
3579
+ }
3580
+
3581
+ // src/index.ts
3582
+ init_github();
2897
3583
 
2898
3584
  // src/lib/agent.ts
3585
+ import ora15 from "ora";
3586
+ import chalk15 from "chalk";
2899
3587
  var tools = toolModules.map((m) => m.definition);
3588
+ var HISTORY_KEEP_TURNS = 8;
3589
+ function trimHistory(messages) {
3590
+ const userIndices = messages.map((m, i) => m.role === "user" ? i : -1).filter((i) => i !== -1);
3591
+ if (userIndices.length <= HISTORY_KEEP_TURNS) return;
3592
+ const compressBefore = userIndices[userIndices.length - HISTORY_KEEP_TURNS];
3593
+ const compressed = [];
3594
+ for (let t = 0; t < userIndices.length - HISTORY_KEEP_TURNS; t++) {
3595
+ const start = userIndices[t];
3596
+ const end = t + 1 < userIndices.length ? userIndices[t + 1] : compressBefore;
3597
+ const turnMessages = messages.slice(start, end);
3598
+ compressed.push(turnMessages[0]);
3599
+ const lastAssistant = [...turnMessages].reverse().find(
3600
+ (m) => m.role === "assistant" && typeof m.content === "string" && !!m.content
3601
+ );
3602
+ if (lastAssistant) {
3603
+ compressed.push({ role: "assistant", content: lastAssistant.content });
3604
+ }
3605
+ }
3606
+ messages.splice(0, compressBefore, ...compressed);
3607
+ }
2900
3608
  async function executeTool(name, input3, config) {
2901
3609
  const mod = toolModules.find((m) => m.definition.function.name === name);
2902
3610
  if (!mod) return `Unknown tool: ${name}`;
@@ -2922,9 +3630,14 @@ async function runAgentLoop(config, messages) {
2922
3630
  "## Tool philosophy",
2923
3631
  "Command tools (pick, new_task, close, submit, my_status, review, refresh, open_code) run",
2924
3632
  "hardcoded interactive flows \u2014 always use these for user-facing actions.",
2925
- "Low-level tools are for reasoning: run_command, scan_project, read_file, ask_user,",
3633
+ "Low-level tools are for reasoning: run_command, grep_code, ask_user,",
2926
3634
  "get_task, get_comments, get_diff.",
2927
3635
  "",
3636
+ "## Exploring the codebase",
3637
+ "1. list_files \u2014 see all files or filter by glob to orient yourself.",
3638
+ "2. grep_code(pattern) \u2014 find where functions/variables appear.",
3639
+ "3. grep_code(file_glob, start_line, end_line) \u2014 read a specific section after grep gives you line numbers.",
3640
+ "",
2928
3641
  "## Creating a task",
2929
3642
  "If the task description is vague, call ask_user to clarify (max 3 times).",
2930
3643
  "Then call new_task with the title \u2014 the tool scans the project and generates the guide automatically.",
@@ -2955,7 +3668,8 @@ async function runAgentLoop(config, messages) {
2955
3668
  if (++iterations > MAX_ITERATIONS) {
2956
3669
  throw new Error(`Agent exceeded ${MAX_ITERATIONS} iterations without finishing.`);
2957
3670
  }
2958
- const spinner = ora15({ text: chalk13.dim("Thinking\u2026"), color: "cyan" }).start();
3671
+ trimHistory(messages);
3672
+ const spinner = ora15({ text: chalk15.dim("Thinking\u2026"), color: "cyan" }).start();
2959
3673
  let response;
2960
3674
  try {
2961
3675
  response = await client.chat.completions.create({
@@ -3000,7 +3714,7 @@ async function runAgentLoop(config, messages) {
3000
3714
  return executeTool(tc.function.name, parsed, config);
3001
3715
  })
3002
3716
  );
3003
- let terminal12 = false;
3717
+ let terminal13 = false;
3004
3718
  for (let i = 0; i < toolCalls.length; i++) {
3005
3719
  printToolResult(results[i]);
3006
3720
  messages.push({
@@ -3009,16 +3723,93 @@ async function runAgentLoop(config, messages) {
3009
3723
  content: results[i]
3010
3724
  });
3011
3725
  if (toolModules.find((m) => m.definition.function.name === toolCalls[i].function.name)?.terminal) {
3012
- terminal12 = true;
3726
+ terminal13 = true;
3013
3727
  }
3014
3728
  }
3015
- if (terminal12) return results[results.length - 1];
3729
+ if (terminal13) return results[results.length - 1];
3016
3730
  } else {
3017
3731
  return choice.message.content ?? "";
3018
3732
  }
3019
3733
  }
3020
3734
  }
3021
3735
 
3736
+ // src/lib/update-check.ts
3737
+ import Conf2 from "conf";
3738
+ import chalk16 from "chalk";
3739
+ import { execFile } from "child_process";
3740
+ var PACKAGE_NAME = "techunter";
3741
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
3742
+ var cache = new Conf2({
3743
+ projectName: "techunter-update-cache",
3744
+ defaults: { lastChecked: 0, latestVersion: "" }
3745
+ });
3746
+ async function fetchLatestVersion() {
3747
+ try {
3748
+ const { fetch } = await import("undici");
3749
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
3750
+ signal: AbortSignal.timeout(5e3)
3751
+ });
3752
+ if (!res.ok) return null;
3753
+ const data = await res.json();
3754
+ return data.version ?? null;
3755
+ } catch {
3756
+ return null;
3757
+ }
3758
+ }
3759
+ function isNewer(latest, current) {
3760
+ const parse = (v) => v.split(".").map(Number);
3761
+ const [la, lb, lc] = parse(latest);
3762
+ const [ca, cb, cc] = parse(current);
3763
+ if (la !== ca) return la > ca;
3764
+ if (lb !== cb) return lb > cb;
3765
+ return lc > cc;
3766
+ }
3767
+ async function getAvailableUpdate(currentVersion) {
3768
+ const now = Date.now();
3769
+ const lastChecked = cache.get("lastChecked");
3770
+ let latest = cache.get("latestVersion");
3771
+ if (!latest || now - lastChecked > CHECK_INTERVAL_MS) {
3772
+ const fetched = await fetchLatestVersion();
3773
+ if (fetched) {
3774
+ latest = fetched;
3775
+ cache.set("latestVersion", fetched);
3776
+ cache.set("lastChecked", now);
3777
+ }
3778
+ }
3779
+ return latest && isNewer(latest, currentVersion) ? latest : null;
3780
+ }
3781
+ function installUpdate() {
3782
+ return new Promise((resolve, reject) => {
3783
+ execFile("npm", ["install", "-g", PACKAGE_NAME], { shell: true }, (err, _stdout, stderr) => {
3784
+ if (err) {
3785
+ reject(new Error(stderr.trim() || err.message));
3786
+ } else {
3787
+ resolve(cache.get("latestVersion"));
3788
+ }
3789
+ });
3790
+ });
3791
+ }
3792
+ async function startAutoUpdate(currentVersion) {
3793
+ const latest = await Promise.race([
3794
+ getAvailableUpdate(currentVersion),
3795
+ new Promise((resolve) => setTimeout(() => resolve(null), 2e3))
3796
+ ]);
3797
+ if (!latest) return;
3798
+ console.log(
3799
+ chalk16.cyan("\n \u2191 Auto-updating to v" + latest + "\u2026") + chalk16.dim(" (running in background)\n")
3800
+ );
3801
+ installUpdate().then((installedVersion) => {
3802
+ console.log(
3803
+ "\n" + chalk16.green(" \u2714 Updated to v" + (installedVersion || latest)) + chalk16.dim(" \u2014 restart tch to use the new version\n") + chalk16.cyan(" You \u203A ")
3804
+ // redraw the prompt hint
3805
+ );
3806
+ }).catch((err) => {
3807
+ console.log(
3808
+ "\n" + chalk16.red(" \u2718 Auto-update failed: ") + chalk16.dim(err.message) + "\n" + chalk16.dim(" Run manually: npm install -g techunter\n") + chalk16.cyan(" You \u203A ")
3809
+ );
3810
+ });
3811
+ }
3812
+
3022
3813
  // src/index.ts
3023
3814
  var _require = createRequire(import.meta.url);
3024
3815
  var { version } = _require("../package.json");
@@ -3045,6 +3836,8 @@ var SLASH_NAMES = [
3045
3836
  "/me",
3046
3837
  "/code",
3047
3838
  "/c",
3839
+ "/wiki",
3840
+ "/w",
3048
3841
  "/config",
3049
3842
  "/cfg",
3050
3843
  "/init"
@@ -3061,7 +3854,7 @@ function promptUser() {
3061
3854
  return new Promise((resolve) => {
3062
3855
  if (process.stdin.isPaused()) process.stdin.resume();
3063
3856
  _rl.resume();
3064
- _rl.question(chalk14.cyan("You") + chalk14.dim(" \u203A "), resolve);
3857
+ _rl.question(chalk17.cyan("You") + chalk17.dim(" \u203A "), resolve);
3065
3858
  });
3066
3859
  }
3067
3860
  var COMMANDS = [
@@ -3077,37 +3870,38 @@ var COMMANDS = [
3077
3870
  { cmd: "/config", alias: "/cfg", desc: "Change settings (branch, repo, API keys)" },
3078
3871
  { cmd: "/init", desc: "Re-run setup wizard for this repo" },
3079
3872
  { cmd: "/status", alias: "/me", desc: "Show tasks assigned to you" },
3080
- { cmd: "/code", alias: "/c", desc: "Open Claude Code for the current task branch" }
3873
+ { cmd: "/code", alias: "/c", desc: "Open Claude Code for the current task branch" },
3874
+ { cmd: "/wiki", alias: "/w", desc: "Generate or refresh TECHUNTER.md project overview" }
3081
3875
  ];
3082
3876
  function cmdHelp() {
3083
3877
  console.log("");
3084
- console.log(chalk14.bold(" Commands"));
3085
- console.log(chalk14.dim(" \u2500".repeat(35)));
3878
+ console.log(chalk17.bold(" Commands"));
3879
+ console.log(chalk17.dim(" \u2500".repeat(35)));
3086
3880
  for (const { cmd, alias, desc } of COMMANDS) {
3087
- const left = (cmd + (alias ? ` ${chalk14.dim(alias)}` : "")).padEnd(22);
3088
- console.log(` ${chalk14.cyan(cmd)}${alias ? " " + chalk14.dim(alias) : ""}`.padEnd(30) + chalk14.dim(desc));
3881
+ const left = (cmd + (alias ? ` ${chalk17.dim(alias)}` : "")).padEnd(22);
3882
+ console.log(` ${chalk17.cyan(cmd)}${alias ? " " + chalk17.dim(alias) : ""}`.padEnd(30) + chalk17.dim(desc));
3089
3883
  }
3090
- console.log(chalk14.dim("\n Anything else is sent to the AI agent.\n"));
3884
+ console.log(chalk17.dim("\n Anything else is sent to the AI agent.\n"));
3091
3885
  }
3092
3886
  function printBanner(config) {
3093
3887
  const { owner, repo } = config.github;
3094
- const g = chalk14.cyan;
3095
- const b = chalk14.bold.white;
3096
- const p = chalk14.yellow.bold;
3888
+ const g = chalk17.cyan;
3889
+ const b = chalk17.bold.white;
3890
+ const p = chalk17.yellow.bold;
3097
3891
  console.log("");
3098
3892
  console.log(" " + g("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557"));
3099
3893
  console.log(p("\u25C6") + b("\u2550\u2550\u2550") + g("\u256C") + b(" TECHUNTER ") + g("\u256C") + b("\u2550\u2550\u2550\u25B6"));
3100
3894
  console.log(" " + g("\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D"));
3101
3895
  console.log("");
3102
3896
  console.log(
3103
- " " + chalk14.bold("Techunter") + chalk14.dim(` v${version}`) + chalk14.dim(" \xB7 ") + chalk14.cyan(getModel(config)) + chalk14.dim(" \xB7 ") + chalk14.dim(`${owner}/${repo}`)
3897
+ " " + chalk17.bold("Techunter") + chalk17.dim(` v${version}`) + chalk17.dim(" \xB7 ") + chalk17.cyan(getModel(config)) + chalk17.dim(" \xB7 ") + chalk17.dim(`${owner}/${repo}`)
3104
3898
  );
3105
3899
  console.log("");
3106
3900
  }
3107
3901
  async function initNewRepo(config, owner, repo) {
3108
3902
  console.log("");
3109
- console.log(chalk14.bold.cyan(` New repo detected: ${owner}/${repo}`));
3110
- console.log(chalk14.dim(" Setting up Techunter for this repository...\n"));
3903
+ console.log(chalk17.bold.cyan(` New repo detected: ${owner}/${repo}`));
3904
+ console.log(chalk17.dim(" Setting up Techunter for this repository...\n"));
3111
3905
  const newConfig = {
3112
3906
  ...config,
3113
3907
  github: { owner, repo }
@@ -3130,7 +3924,7 @@ async function main() {
3130
3924
  try {
3131
3925
  await configCommand();
3132
3926
  } catch (err) {
3133
- console.error(chalk14.red(`
3927
+ console.error(chalk17.red(`
3134
3928
  Error: ${err.message}`));
3135
3929
  process.exit(1);
3136
3930
  }
@@ -3144,7 +3938,7 @@ Error: ${err.message}`));
3144
3938
  await initCommand();
3145
3939
  config = getConfig();
3146
3940
  } catch (err) {
3147
- console.error(chalk14.red(`
3941
+ console.error(chalk17.red(`
3148
3942
  Setup failed: ${err.message}`));
3149
3943
  process.exit(1);
3150
3944
  return;
@@ -3162,11 +3956,13 @@ Setup failed: ${err.message}`));
3162
3956
  }
3163
3957
  }
3164
3958
  } else if (!config.github.owner) {
3165
- console.error(chalk14.red("\nNo git remote found and no repo configured. Run tch init."));
3959
+ console.error(chalk17.red("\nNo git remote found and no repo configured. Run tch init."));
3166
3960
  process.exit(1);
3167
3961
  }
3168
3962
  printBanner(config);
3169
- console.log(chalk14.dim(" Type /help for commands, or describe what you want.\n"));
3963
+ console.log(chalk17.dim(" Type /help for commands, or describe what you want.\n"));
3964
+ startAutoUpdate(version).catch(() => {
3965
+ });
3170
3966
  await printTaskList(config);
3171
3967
  await printMyTasks(config);
3172
3968
  _rl = readline.createInterface({
@@ -3176,11 +3972,11 @@ Setup failed: ${err.message}`));
3176
3972
  terminal: true
3177
3973
  });
3178
3974
  _rl.on("close", () => {
3179
- console.log(chalk14.gray("\nGoodbye!"));
3975
+ console.log(chalk17.gray("\nGoodbye!"));
3180
3976
  process.exit(0);
3181
3977
  });
3182
3978
  _rl.on("SIGINT", () => {
3183
- console.log(chalk14.gray("\nGoodbye!"));
3979
+ console.log(chalk17.gray("\nGoodbye!"));
3184
3980
  process.exit(0);
3185
3981
  });
3186
3982
  const messages = [];
@@ -3197,7 +3993,7 @@ Setup failed: ${err.message}`));
3197
3993
  break;
3198
3994
  case "/refresh":
3199
3995
  case "/r":
3200
- await run7({}, config);
3996
+ await run9({}, config);
3201
3997
  break;
3202
3998
  case "/pick":
3203
3999
  case "/p": {
@@ -3205,7 +4001,7 @@ Setup failed: ${err.message}`));
3205
4001
  const preselected = arg ? parseInt(arg, 10) : void 0;
3206
4002
  const result = await run3({ issue_number: Number.isNaN(preselected) ? void 0 : preselected }, config);
3207
4003
  if (result && result !== "Cancelled.") {
3208
- console.log(chalk14.green(`
4004
+ console.log(chalk17.green(`
3209
4005
  ${result}
3210
4006
  `));
3211
4007
  }
@@ -3215,7 +4011,7 @@ Setup failed: ${err.message}`));
3215
4011
  case "/new":
3216
4012
  case "/n": {
3217
4013
  const result = await run4({}, config);
3218
- console.log(chalk14.green(`
4014
+ console.log(chalk17.green(`
3219
4015
  ${result}
3220
4016
  `));
3221
4017
  await printTaskList(config);
@@ -3233,7 +4029,7 @@ Setup failed: ${err.message}`));
3233
4029
  const arg = userInput.slice(cmd.length).trim().replace(/^#/, "");
3234
4030
  const preselected = arg ? parseInt(arg, 10) : void 0;
3235
4031
  const result = await run11({ issue_number: Number.isNaN(preselected) ? void 0 : preselected }, config);
3236
- console.log(chalk14.green(`
4032
+ console.log(chalk17.green(`
3237
4033
  ${result}
3238
4034
  `));
3239
4035
  await printTaskList(config);
@@ -3242,7 +4038,7 @@ Setup failed: ${err.message}`));
3242
4038
  case "/close":
3243
4039
  case "/d": {
3244
4040
  const result = await run2({}, config);
3245
- console.log(chalk14.green(`
4041
+ console.log(chalk17.green(`
3246
4042
  ${result}
3247
4043
  `));
3248
4044
  await printTaskList(config);
@@ -3250,7 +4046,7 @@ Setup failed: ${err.message}`));
3250
4046
  }
3251
4047
  case "/review":
3252
4048
  case "/rv": {
3253
- const result = await run6({}, config);
4049
+ const result = await run8({}, config);
3254
4050
  console.log("\n" + renderMarkdown(result));
3255
4051
  break;
3256
4052
  }
@@ -3264,8 +4060,8 @@ Setup failed: ${err.message}`));
3264
4060
  case "/ac": {
3265
4061
  const arg = userInput.slice(cmd.length).trim().replace(/^#/, "");
3266
4062
  const preselected = arg ? parseInt(arg, 10) : void 0;
3267
- const result = await run10({ issue_number: Number.isNaN(preselected) ? void 0 : preselected }, config);
3268
- console.log(chalk14.green(`
4063
+ const result = await run6({ issue_number: Number.isNaN(preselected) ? void 0 : preselected }, config);
4064
+ console.log(chalk17.green(`
3269
4065
  ${result}
3270
4066
  `));
3271
4067
  await printTaskList(config);
@@ -3281,17 +4077,25 @@ Setup failed: ${err.message}`));
3281
4077
  config = getConfig();
3282
4078
  await printTaskList(config);
3283
4079
  } catch (err) {
3284
- console.error(chalk14.red(`
4080
+ console.error(chalk17.red(`
3285
4081
  Init failed: ${err.message}
3286
4082
  `));
3287
4083
  }
3288
4084
  break;
3289
4085
  case "/code":
3290
4086
  case "/c":
3291
- await run8({}, config);
4087
+ await run10({}, config);
3292
4088
  break;
4089
+ case "/wiki":
4090
+ case "/w": {
4091
+ const result = await run12({}, config);
4092
+ console.log(chalk17.green(`
4093
+ ${result}
4094
+ `));
4095
+ break;
4096
+ }
3293
4097
  default:
3294
- console.log(chalk14.yellow(` Unknown command: ${cmd} (try /help)`));
4098
+ console.log(chalk17.yellow(` Unknown command: ${cmd} (try /help)`));
3295
4099
  }
3296
4100
  continue;
3297
4101
  }
@@ -3299,10 +4103,10 @@ Init failed: ${err.message}
3299
4103
  messages.push({ role: "user", content: userInput });
3300
4104
  try {
3301
4105
  const response = await runAgentLoop(config, messages);
3302
- console.log("\n" + chalk14.green("Techunter:") + "\n" + renderMarkdown(response));
4106
+ console.log("\n" + chalk17.green("Techunter:") + "\n" + renderMarkdown(response));
3303
4107
  } catch (err) {
3304
4108
  messages.splice(prevLength);
3305
- console.error(chalk14.red(`
4109
+ console.error(chalk17.red(`
3306
4110
  Error: ${err.message}
3307
4111
  `));
3308
4112
  }
@@ -3310,6 +4114,6 @@ Error: ${err.message}
3310
4114
  }
3311
4115
  }
3312
4116
  main().catch((err) => {
3313
- console.error(chalk14.red(`Fatal: ${err.message}`));
4117
+ console.error(chalk17.red(`Fatal: ${err.message}`));
3314
4118
  process.exit(1);
3315
4119
  });