claude-kanban 0.3.0 → 0.4.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.
@@ -9,7 +9,7 @@ import { existsSync as existsSync3 } from "fs";
9
9
  // src/server/services/executor.ts
10
10
  import { spawn, execSync } from "child_process";
11
11
  import { join as join4 } from "path";
12
- import { writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync2, existsSync as existsSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync4, rmSync, symlinkSync } from "fs";
12
+ import { writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync2, existsSync as existsSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync4, rmSync } from "fs";
13
13
  import { EventEmitter } from "events";
14
14
 
15
15
  // src/server/services/project.ts
@@ -134,6 +134,7 @@ function getTaskCounts(projectPath) {
134
134
  draft: 0,
135
135
  ready: 0,
136
136
  in_progress: 0,
137
+ in_review: 0,
137
138
  completed: 0,
138
139
  failed: 0
139
140
  };
@@ -210,6 +211,7 @@ var WORKTREES_DIR = "worktrees";
210
211
  var TaskExecutor = class extends EventEmitter {
211
212
  projectPath;
212
213
  runningTasks = /* @__PURE__ */ new Map();
214
+ reviewingTasks = /* @__PURE__ */ new Map();
213
215
  afkMode = false;
214
216
  afkIteration = 0;
215
217
  afkMaxIterations = 0;
@@ -379,7 +381,6 @@ ${summary}
379
381
  cwd: this.projectPath,
380
382
  stdio: "pipe"
381
383
  });
382
- this.symlinkDependencies(worktreePath);
383
384
  console.log(`[executor] Created worktree at ${worktreePath} on branch ${branchName}`);
384
385
  return { worktreePath, branchName };
385
386
  } catch (error) {
@@ -387,38 +388,6 @@ ${summary}
387
388
  return null;
388
389
  }
389
390
  }
390
- /**
391
- * Symlink dependency directories from main project to worktree
392
- */
393
- symlinkDependencies(worktreePath) {
394
- const depDirs = [
395
- "node_modules",
396
- ".pnpm",
397
- // pnpm store
398
- ".yarn",
399
- // yarn cache
400
- "vendor",
401
- // PHP/Ruby deps
402
- "__pycache__",
403
- // Python cache
404
- ".venv",
405
- // Python virtual env
406
- "venv"
407
- // Python virtual env
408
- ];
409
- for (const dir of depDirs) {
410
- const sourcePath = join4(this.projectPath, dir);
411
- const targetPath = join4(worktreePath, dir);
412
- if (existsSync2(sourcePath) && !existsSync2(targetPath)) {
413
- try {
414
- symlinkSync(sourcePath, targetPath, "junction");
415
- console.log(`[executor] Symlinked ${dir} to worktree`);
416
- } catch (error) {
417
- console.log(`[executor] Could not symlink ${dir}:`, error);
418
- }
419
- }
420
- }
421
- }
422
391
  /**
423
392
  * Remove a git worktree
424
393
  */
@@ -504,7 +473,7 @@ ${summary}
504
473
  /**
505
474
  * Build the prompt for a specific task
506
475
  */
507
- buildTaskPrompt(task, config) {
476
+ buildTaskPrompt(task, config, worktreeInfo) {
508
477
  const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
509
478
  const prdPath = join4(kanbanDir, "prd.json");
510
479
  const progressPath = join4(kanbanDir, "progress.txt");
@@ -518,9 +487,15 @@ ${task.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}` : "";
518
487
  if (config.project.testCommand) {
519
488
  verifySteps.push(`Run tests: ${config.project.testCommand}`);
520
489
  }
521
- const verifySection = verifySteps.length > 0 ? `2. Verify your work:
490
+ const verifySection = verifySteps.length > 0 ? `3. Verify your work:
522
491
  ${verifySteps.map((s) => ` - ${s}`).join("\n")}
523
492
 
493
+ ` : "";
494
+ const worktreeSection = worktreeInfo ? `## ENVIRONMENT
495
+ You are running in an isolated git worktree on branch "${worktreeInfo.branchName}".
496
+ This is a fresh checkout - dependencies (node_modules, vendor, etc.) are NOT installed.
497
+ Before running any build/test commands, install dependencies first (e.g., npm install, composer install, pip install).
498
+
524
499
  ` : "";
525
500
  return `You are an AI coding agent. Complete the following task:
526
501
 
@@ -532,22 +507,24 @@ Priority: ${task.priority}
532
507
  ${task.description}
533
508
  ${stepsText}
534
509
 
535
- ## INSTRUCTIONS
536
- 1. Implement this task as described above.
510
+ ${worktreeSection}## INSTRUCTIONS
511
+ 1. If dependencies are not installed, install them first.
512
+
513
+ 2. Implement this task as described above.
537
514
 
538
- ${verifySection}${verifySteps.length > 0 ? "3" : "2"}. When complete, update the task in ${prdPath}:
515
+ ${verifySection}${verifySteps.length > 0 ? "4" : "3"}. When complete, update the task in ${prdPath}:
539
516
  - Find the task with id "${task.id}"
540
517
  - Set "passes": true
541
- - Set "status": "completed"
518
+ (Note: Do NOT change the status - the system will move it to review)
542
519
 
543
- ${verifySteps.length > 0 ? "4" : "3"}. Document your work in ${progressPath}:
520
+ ${verifySteps.length > 0 ? "5" : "4"}. Document your work in ${progressPath}:
544
521
  - What you implemented and files changed
545
522
  - Key decisions made and why
546
523
  - Gotchas, edge cases, or tricky parts discovered
547
524
  - Useful patterns or approaches that worked well
548
525
  - Anything a future agent should know about this area of the codebase
549
526
 
550
- ${verifySteps.length > 0 ? "5" : "4"}. Make a git commit with a descriptive message.
527
+ ${verifySteps.length > 0 ? "6" : "5"}. Make a git commit with a descriptive message.
551
528
 
552
529
  Focus only on this task. When successfully complete, output: <promise>COMPLETE</promise>`;
553
530
  }
@@ -571,7 +548,7 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
571
548
  const startedAt = /* @__PURE__ */ new Date();
572
549
  const worktreeInfo = this.createWorktree(taskId);
573
550
  const executionPath = worktreeInfo?.worktreePath || this.projectPath;
574
- const prompt = this.buildTaskPrompt(task, config);
551
+ const prompt = this.buildTaskPrompt(task, config, worktreeInfo);
575
552
  const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
576
553
  const promptFile = join4(kanbanDir, `prompt-${taskId}.txt`);
577
554
  writeFileSync4(promptFile, prompt);
@@ -717,7 +694,7 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
717
694
  }, timeoutMs);
718
695
  }
719
696
  /**
720
- * Handle task completion
697
+ * Handle task completion - moves to in_review, keeps worktree alive
721
698
  */
722
699
  handleTaskComplete(taskId, exitCode, startedAt) {
723
700
  const runningTask = this.runningTasks.get(taskId);
@@ -729,31 +706,25 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
729
706
  const task = getTaskById(this.projectPath, taskId);
730
707
  if (isComplete || exitCode === 0) {
731
708
  if (runningTask.worktreePath && runningTask.branchName) {
732
- const merged = this.mergeWorktreeBranch(taskId);
733
- if (merged) {
734
- console.log(`[executor] Successfully merged ${runningTask.branchName}`);
735
- } else {
736
- console.log(`[executor] Failed to merge ${runningTask.branchName}, branch preserved for manual merge`);
737
- }
738
- this.removeWorktree(taskId);
709
+ this.reviewingTasks.set(taskId, {
710
+ worktreePath: runningTask.worktreePath,
711
+ branchName: runningTask.branchName,
712
+ startedAt,
713
+ endedAt
714
+ });
715
+ console.log(`[executor] Task ${taskId} ready for review at ${runningTask.worktreePath}`);
739
716
  }
740
717
  updateTask(this.projectPath, taskId, {
741
- status: "completed",
718
+ status: "in_review",
742
719
  passes: true
743
720
  });
744
- addExecutionEntry(this.projectPath, taskId, {
745
- startedAt: startedAt.toISOString(),
746
- endedAt: endedAt.toISOString(),
747
- status: "completed",
748
- duration
749
- });
750
721
  logTaskExecution(this.projectPath, {
751
722
  taskId,
752
723
  taskTitle: task?.title || "Unknown",
753
- status: "completed",
724
+ status: "in_review",
754
725
  duration
755
726
  });
756
- this.emit("task:completed", { taskId, duration });
727
+ this.emit("task:completed", { taskId, duration, status: "in_review" });
757
728
  this.afkTasksCompleted++;
758
729
  } else {
759
730
  if (runningTask.worktreePath) {
@@ -785,6 +756,151 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
785
756
  this.continueAFKMode();
786
757
  }
787
758
  }
759
+ /**
760
+ * Get info about a task in review
761
+ */
762
+ getReviewingTask(taskId) {
763
+ return this.reviewingTasks.get(taskId);
764
+ }
765
+ /**
766
+ * Merge a reviewed task into the base branch
767
+ */
768
+ mergeTask(taskId) {
769
+ const reviewInfo = this.reviewingTasks.get(taskId);
770
+ if (!reviewInfo) {
771
+ return { success: false, error: "Task not in review or no worktree found" };
772
+ }
773
+ const merged = this.mergeWorktreeBranch(taskId);
774
+ if (!merged) {
775
+ return { success: false, error: "Merge failed - may have conflicts" };
776
+ }
777
+ this.removeWorktree(taskId);
778
+ this.reviewingTasks.delete(taskId);
779
+ updateTask(this.projectPath, taskId, {
780
+ status: "completed"
781
+ });
782
+ addExecutionEntry(this.projectPath, taskId, {
783
+ startedAt: reviewInfo.startedAt.toISOString(),
784
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
785
+ status: "completed",
786
+ duration: (/* @__PURE__ */ new Date()).getTime() - reviewInfo.startedAt.getTime()
787
+ });
788
+ console.log(`[executor] Task ${taskId} merged successfully`);
789
+ return { success: true };
790
+ }
791
+ /**
792
+ * Create a PR for a reviewed task
793
+ */
794
+ createPR(taskId) {
795
+ const reviewInfo = this.reviewingTasks.get(taskId);
796
+ const task = getTaskById(this.projectPath, taskId);
797
+ if (!reviewInfo) {
798
+ return { success: false, error: "Task not in review or no worktree found" };
799
+ }
800
+ try {
801
+ execSync(`git push -u origin ${reviewInfo.branchName}`, {
802
+ cwd: this.projectPath,
803
+ stdio: "pipe"
804
+ });
805
+ const prTitle = task?.title || `Task ${taskId}`;
806
+ const prBody = task?.description || "";
807
+ const result = execSync(
808
+ `gh pr create --title "${prTitle.replace(/"/g, '\\"')}" --body "${prBody.replace(/"/g, '\\"')}" --head ${reviewInfo.branchName}`,
809
+ {
810
+ cwd: this.projectPath,
811
+ encoding: "utf-8"
812
+ }
813
+ );
814
+ const prUrl = result.trim();
815
+ try {
816
+ execSync(`git worktree remove "${reviewInfo.worktreePath}" --force`, {
817
+ cwd: this.projectPath,
818
+ stdio: "pipe"
819
+ });
820
+ } catch {
821
+ }
822
+ this.reviewingTasks.delete(taskId);
823
+ updateTask(this.projectPath, taskId, {
824
+ status: "completed"
825
+ });
826
+ addExecutionEntry(this.projectPath, taskId, {
827
+ startedAt: reviewInfo.startedAt.toISOString(),
828
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
829
+ status: "completed",
830
+ duration: (/* @__PURE__ */ new Date()).getTime() - reviewInfo.startedAt.getTime()
831
+ });
832
+ console.log(`[executor] PR created for task ${taskId}: ${prUrl}`);
833
+ return { success: true, prUrl };
834
+ } catch (error) {
835
+ const errorMsg = error instanceof Error ? error.message : String(error);
836
+ console.error(`[executor] Failed to create PR:`, errorMsg);
837
+ return { success: false, error: errorMsg };
838
+ }
839
+ }
840
+ /**
841
+ * Discard a reviewed task - delete worktree and return to ready
842
+ */
843
+ discardTask(taskId) {
844
+ const reviewInfo = this.reviewingTasks.get(taskId);
845
+ if (!reviewInfo) {
846
+ return { success: false, error: "Task not in review or no worktree found" };
847
+ }
848
+ this.removeWorktree(taskId);
849
+ this.reviewingTasks.delete(taskId);
850
+ updateTask(this.projectPath, taskId, {
851
+ status: "ready",
852
+ passes: false
853
+ });
854
+ addExecutionEntry(this.projectPath, taskId, {
855
+ startedAt: reviewInfo.startedAt.toISOString(),
856
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
857
+ status: "discarded",
858
+ duration: (/* @__PURE__ */ new Date()).getTime() - reviewInfo.startedAt.getTime()
859
+ });
860
+ console.log(`[executor] Task ${taskId} discarded, returned to ready`);
861
+ return { success: true };
862
+ }
863
+ /**
864
+ * Get diff for a task in review
865
+ */
866
+ getTaskDiff(taskId) {
867
+ const reviewInfo = this.reviewingTasks.get(taskId);
868
+ if (!reviewInfo) {
869
+ return { success: false, error: "Task not in review or no worktree found" };
870
+ }
871
+ try {
872
+ const diff = execSync(`git diff main...${reviewInfo.branchName}`, {
873
+ cwd: this.projectPath,
874
+ encoding: "utf-8",
875
+ maxBuffer: 10 * 1024 * 1024
876
+ // 10MB buffer for large diffs
877
+ });
878
+ return { success: true, diff };
879
+ } catch (error) {
880
+ const errorMsg = error instanceof Error ? error.message : String(error);
881
+ return { success: false, error: errorMsg };
882
+ }
883
+ }
884
+ /**
885
+ * Get list of changed files for a task in review
886
+ */
887
+ getTaskChangedFiles(taskId) {
888
+ const reviewInfo = this.reviewingTasks.get(taskId);
889
+ if (!reviewInfo) {
890
+ return { success: false, error: "Task not in review or no worktree found" };
891
+ }
892
+ try {
893
+ const result = execSync(`git diff --name-only main...${reviewInfo.branchName}`, {
894
+ cwd: this.projectPath,
895
+ encoding: "utf-8"
896
+ });
897
+ const files = result.trim().split("\n").filter((f) => f);
898
+ return { success: true, files };
899
+ } catch (error) {
900
+ const errorMsg = error instanceof Error ? error.message : String(error);
901
+ return { success: false, error: errorMsg };
902
+ }
903
+ }
788
904
  /**
789
905
  * Cancel a running task
790
906
  */
@@ -1451,6 +1567,90 @@ async function createServer(projectPath, port) {
1451
1567
  res.status(500).json({ error: String(error) });
1452
1568
  }
1453
1569
  });
1570
+ app.post("/api/tasks/:id/merge", (req, res) => {
1571
+ try {
1572
+ const result = executor.mergeTask(req.params.id);
1573
+ if (!result.success) {
1574
+ res.status(400).json({ error: result.error });
1575
+ return;
1576
+ }
1577
+ const task = getTaskById(projectPath, req.params.id);
1578
+ if (task) {
1579
+ io.emit("task:updated", task);
1580
+ }
1581
+ res.json({ success: true });
1582
+ } catch (error) {
1583
+ res.status(500).json({ error: String(error) });
1584
+ }
1585
+ });
1586
+ app.post("/api/tasks/:id/create-pr", (req, res) => {
1587
+ try {
1588
+ const result = executor.createPR(req.params.id);
1589
+ if (!result.success) {
1590
+ res.status(400).json({ error: result.error });
1591
+ return;
1592
+ }
1593
+ const task = getTaskById(projectPath, req.params.id);
1594
+ if (task) {
1595
+ io.emit("task:updated", task);
1596
+ }
1597
+ res.json({ success: true, prUrl: result.prUrl });
1598
+ } catch (error) {
1599
+ res.status(500).json({ error: String(error) });
1600
+ }
1601
+ });
1602
+ app.post("/api/tasks/:id/discard", (req, res) => {
1603
+ try {
1604
+ const result = executor.discardTask(req.params.id);
1605
+ if (!result.success) {
1606
+ res.status(400).json({ error: result.error });
1607
+ return;
1608
+ }
1609
+ const task = getTaskById(projectPath, req.params.id);
1610
+ if (task) {
1611
+ io.emit("task:updated", task);
1612
+ }
1613
+ res.json({ success: true });
1614
+ } catch (error) {
1615
+ res.status(500).json({ error: String(error) });
1616
+ }
1617
+ });
1618
+ app.get("/api/tasks/:id/diff", (req, res) => {
1619
+ try {
1620
+ const result = executor.getTaskDiff(req.params.id);
1621
+ if (!result.success) {
1622
+ res.status(400).json({ error: result.error });
1623
+ return;
1624
+ }
1625
+ res.json({ diff: result.diff });
1626
+ } catch (error) {
1627
+ res.status(500).json({ error: String(error) });
1628
+ }
1629
+ });
1630
+ app.get("/api/tasks/:id/changed-files", (req, res) => {
1631
+ try {
1632
+ const result = executor.getTaskChangedFiles(req.params.id);
1633
+ if (!result.success) {
1634
+ res.status(400).json({ error: result.error });
1635
+ return;
1636
+ }
1637
+ res.json({ files: result.files });
1638
+ } catch (error) {
1639
+ res.status(500).json({ error: String(error) });
1640
+ }
1641
+ });
1642
+ app.get("/api/tasks/:id/review-info", (req, res) => {
1643
+ try {
1644
+ const info = executor.getReviewingTask(req.params.id);
1645
+ if (!info) {
1646
+ res.status(404).json({ error: "Task not in review" });
1647
+ return;
1648
+ }
1649
+ res.json(info);
1650
+ } catch (error) {
1651
+ res.status(500).json({ error: String(error) });
1652
+ }
1653
+ });
1454
1654
  app.get("/api/tasks/:id/logs", (req, res) => {
1455
1655
  try {
1456
1656
  const logs = executor.getTaskLog(req.params.id);
@@ -1760,6 +1960,7 @@ function getClientHTML() {
1760
1960
  .status-dot-draft { background: #a3a3a3; }
1761
1961
  .status-dot-ready { background: #3b82f6; }
1762
1962
  .status-dot-in_progress { background: #f97316; }
1963
+ .status-dot-in_review { background: #8b5cf6; }
1763
1964
  .status-dot-completed { background: #22c55e; }
1764
1965
  .status-dot-failed { background: #ef4444; }
1765
1966
 
@@ -1969,6 +2170,7 @@ function getClientHTML() {
1969
2170
  .status-badge-draft { background: #f5f5f5; color: #737373; }
1970
2171
  .status-badge-ready { background: #eff6ff; color: #3b82f6; }
1971
2172
  .status-badge-in_progress { background: #fff7ed; color: #f97316; }
2173
+ .status-badge-in_review { background: #f5f3ff; color: #8b5cf6; }
1972
2174
  .status-badge-completed { background: #f0fdf4; color: #22c55e; }
1973
2175
  .status-badge-failed { background: #fef2f2; color: #ef4444; }
1974
2176
 
@@ -2294,11 +2496,16 @@ socket.on('task:output', ({ taskId, line }) => {
2294
2496
  }
2295
2497
  });
2296
2498
 
2297
- socket.on('task:completed', ({ taskId }) => {
2499
+ socket.on('task:completed', ({ taskId, status }) => {
2298
2500
  state.running = state.running.filter(id => id !== taskId);
2299
2501
  const task = state.tasks.find(t => t.id === taskId);
2300
- if (task) { task.status = 'completed'; task.passes = true; }
2301
- showToast('Task completed: ' + (task?.title || taskId), 'success');
2502
+ if (task) {
2503
+ // Use status from server (could be 'in_review' or 'completed')
2504
+ task.status = status || 'in_review';
2505
+ task.passes = true;
2506
+ }
2507
+ const statusMsg = (status === 'in_review') ? 'ready for review' : 'completed';
2508
+ showToast('Task ' + statusMsg + ': ' + (task?.title || taskId), 'success');
2302
2509
  render();
2303
2510
  });
2304
2511
 
@@ -2372,6 +2579,53 @@ async function retryTask(id) {
2372
2579
  });
2373
2580
  }
2374
2581
 
2582
+ async function mergeTask(id) {
2583
+ const res = await fetch('/api/tasks/' + id + '/merge', { method: 'POST' });
2584
+ const data = await res.json();
2585
+ if (!res.ok) {
2586
+ throw new Error(data.error || 'Merge failed');
2587
+ }
2588
+ return data;
2589
+ }
2590
+
2591
+ async function createPR(id) {
2592
+ const res = await fetch('/api/tasks/' + id + '/create-pr', { method: 'POST' });
2593
+ const data = await res.json();
2594
+ if (!res.ok) {
2595
+ throw new Error(data.error || 'PR creation failed');
2596
+ }
2597
+ return data;
2598
+ }
2599
+
2600
+ async function discardTask(id) {
2601
+ const res = await fetch('/api/tasks/' + id + '/discard', { method: 'POST' });
2602
+ const data = await res.json();
2603
+ if (!res.ok) {
2604
+ throw new Error(data.error || 'Discard failed');
2605
+ }
2606
+ return data;
2607
+ }
2608
+
2609
+ async function getReviewInfo(id) {
2610
+ const res = await fetch('/api/tasks/' + id + '/review-info');
2611
+ const data = await res.json();
2612
+ if (!res.ok) {
2613
+ throw new Error(data.error || 'Failed to get review info');
2614
+ }
2615
+ return data;
2616
+ }
2617
+
2618
+ async function openInVSCode(id) {
2619
+ try {
2620
+ const info = await getReviewInfo(id);
2621
+ // Open VS Code at the worktree path
2622
+ window.open('vscode://file/' + encodeURIComponent(info.worktreePath), '_blank');
2623
+ showToast('Opening in VS Code...', 'info');
2624
+ } catch (e) {
2625
+ showToast('Failed to open in VS Code: ' + e.message, 'error');
2626
+ }
2627
+ }
2628
+
2375
2629
  async function generateTask(prompt) {
2376
2630
  state.aiGenerating = true;
2377
2631
  render();
@@ -2575,6 +2829,7 @@ function renderColumn(status, title, tasks) {
2575
2829
  draft: 'To Do',
2576
2830
  ready: 'Ready',
2577
2831
  in_progress: 'In Progress',
2832
+ in_review: 'In Review',
2578
2833
  completed: 'Done',
2579
2834
  failed: 'Failed'
2580
2835
  };
@@ -2940,7 +3195,7 @@ function renderSidePanel() {
2940
3195
  </div>
2941
3196
 
2942
3197
  <!-- Action Buttons -->
2943
- <div class="px-5 py-4 border-b border-canvas-200 flex gap-2 flex-shrink-0">
3198
+ <div class="px-5 py-4 border-b border-canvas-200 flex flex-wrap gap-2 flex-shrink-0">
2944
3199
  \${task.status === 'draft' ? \`
2945
3200
  <button onclick="updateTask('\${task.id}', { status: 'ready' })" class="btn btn-primary px-4 py-2 text-sm">
2946
3201
  \u2192 Move to Ready
@@ -2956,6 +3211,20 @@ function renderSidePanel() {
2956
3211
  \u23F9 Stop Attempt
2957
3212
  </button>
2958
3213
  \` : ''}
3214
+ \${task.status === 'in_review' ? \`
3215
+ <button onclick="handleMerge('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
3216
+ \u2713 Merge
3217
+ </button>
3218
+ <button onclick="handleCreatePR('\${task.id}')" class="btn btn-ghost px-4 py-2 text-sm">
3219
+ \u21E1 Create PR
3220
+ </button>
3221
+ <button onclick="openInVSCode('\${task.id}')" class="btn btn-ghost px-4 py-2 text-sm">
3222
+ \u{1F4C2} Open in VS Code
3223
+ </button>
3224
+ <button onclick="handleDiscard('\${task.id}')" class="btn btn-danger px-4 py-2 text-sm">
3225
+ \u2715 Discard
3226
+ </button>
3227
+ \` : ''}
2959
3228
  \${task.status === 'failed' ? \`
2960
3229
  <button onclick="retryTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
2961
3230
  \u21BB Retry
@@ -3156,6 +3425,41 @@ function handleStartAFK() {
3156
3425
  render();
3157
3426
  }
3158
3427
 
3428
+ async function handleMerge(taskId) {
3429
+ if (!confirm('Merge this task into the main branch?')) return;
3430
+ try {
3431
+ await mergeTask(taskId);
3432
+ showToast('Task merged successfully!', 'success');
3433
+ closeSidePanel();
3434
+ } catch (e) {
3435
+ showToast('Merge failed: ' + e.message, 'error');
3436
+ }
3437
+ }
3438
+
3439
+ async function handleCreatePR(taskId) {
3440
+ try {
3441
+ const result = await createPR(taskId);
3442
+ showToast('PR created successfully!', 'success');
3443
+ if (result.prUrl) {
3444
+ window.open(result.prUrl, '_blank');
3445
+ }
3446
+ closeSidePanel();
3447
+ } catch (e) {
3448
+ showToast('PR creation failed: ' + e.message, 'error');
3449
+ }
3450
+ }
3451
+
3452
+ async function handleDiscard(taskId) {
3453
+ if (!confirm('Discard this work? The task will return to Ready status.')) return;
3454
+ try {
3455
+ await discardTask(taskId);
3456
+ showToast('Task discarded, returned to Ready', 'warning');
3457
+ closeSidePanel();
3458
+ } catch (e) {
3459
+ showToast('Discard failed: ' + e.message, 'error');
3460
+ }
3461
+ }
3462
+
3159
3463
  // Filter tasks based on search query
3160
3464
  function filterTasks(tasks) {
3161
3465
  if (!state.searchQuery) return tasks;
@@ -3217,6 +3521,7 @@ function render() {
3217
3521
  \${renderColumn('draft', 'To Do', filterTasks(state.tasks))}
3218
3522
  \${renderColumn('ready', 'Ready', filterTasks(state.tasks))}
3219
3523
  \${renderColumn('in_progress', 'In Progress', filterTasks(state.tasks))}
3524
+ \${renderColumn('in_review', 'In Review', filterTasks(state.tasks))}
3220
3525
  \${renderColumn('completed', 'Done', filterTasks(state.tasks))}
3221
3526
  \${renderColumn('failed', 'Failed', filterTasks(state.tasks))}
3222
3527
  </div>
@@ -3281,6 +3586,14 @@ window.closeSidePanel = closeSidePanel;
3281
3586
  window.showTaskMenu = showTaskMenu;
3282
3587
  window.filterTasks = filterTasks;
3283
3588
  window.scrollSidePanelLog = scrollSidePanelLog;
3589
+ window.mergeTask = mergeTask;
3590
+ window.createPR = createPR;
3591
+ window.discardTask = discardTask;
3592
+ window.handleMerge = handleMerge;
3593
+ window.handleCreatePR = handleCreatePR;
3594
+ window.handleDiscard = handleDiscard;
3595
+ window.openInVSCode = openInVSCode;
3596
+ window.getReviewInfo = getReviewInfo;
3284
3597
 
3285
3598
  // Keyboard shortcuts
3286
3599
  document.addEventListener('keydown', (e) => {