claude-kanban 0.3.1 → 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.
@@ -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;
@@ -513,7 +515,7 @@ ${worktreeSection}## INSTRUCTIONS
513
515
  ${verifySection}${verifySteps.length > 0 ? "4" : "3"}. When complete, update the task in ${prdPath}:
514
516
  - Find the task with id "${task.id}"
515
517
  - Set "passes": true
516
- - Set "status": "completed"
518
+ (Note: Do NOT change the status - the system will move it to review)
517
519
 
518
520
  ${verifySteps.length > 0 ? "5" : "4"}. Document your work in ${progressPath}:
519
521
  - What you implemented and files changed
@@ -692,7 +694,7 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
692
694
  }, timeoutMs);
693
695
  }
694
696
  /**
695
- * Handle task completion
697
+ * Handle task completion - moves to in_review, keeps worktree alive
696
698
  */
697
699
  handleTaskComplete(taskId, exitCode, startedAt) {
698
700
  const runningTask = this.runningTasks.get(taskId);
@@ -704,31 +706,25 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
704
706
  const task = getTaskById(this.projectPath, taskId);
705
707
  if (isComplete || exitCode === 0) {
706
708
  if (runningTask.worktreePath && runningTask.branchName) {
707
- const merged = this.mergeWorktreeBranch(taskId);
708
- if (merged) {
709
- console.log(`[executor] Successfully merged ${runningTask.branchName}`);
710
- } else {
711
- console.log(`[executor] Failed to merge ${runningTask.branchName}, branch preserved for manual merge`);
712
- }
713
- 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}`);
714
716
  }
715
717
  updateTask(this.projectPath, taskId, {
716
- status: "completed",
718
+ status: "in_review",
717
719
  passes: true
718
720
  });
719
- addExecutionEntry(this.projectPath, taskId, {
720
- startedAt: startedAt.toISOString(),
721
- endedAt: endedAt.toISOString(),
722
- status: "completed",
723
- duration
724
- });
725
721
  logTaskExecution(this.projectPath, {
726
722
  taskId,
727
723
  taskTitle: task?.title || "Unknown",
728
- status: "completed",
724
+ status: "in_review",
729
725
  duration
730
726
  });
731
- this.emit("task:completed", { taskId, duration });
727
+ this.emit("task:completed", { taskId, duration, status: "in_review" });
732
728
  this.afkTasksCompleted++;
733
729
  } else {
734
730
  if (runningTask.worktreePath) {
@@ -760,6 +756,151 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
760
756
  this.continueAFKMode();
761
757
  }
762
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
+ }
763
904
  /**
764
905
  * Cancel a running task
765
906
  */
@@ -1426,6 +1567,90 @@ async function createServer(projectPath, port) {
1426
1567
  res.status(500).json({ error: String(error) });
1427
1568
  }
1428
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
+ });
1429
1654
  app.get("/api/tasks/:id/logs", (req, res) => {
1430
1655
  try {
1431
1656
  const logs = executor.getTaskLog(req.params.id);
@@ -1735,6 +1960,7 @@ function getClientHTML() {
1735
1960
  .status-dot-draft { background: #a3a3a3; }
1736
1961
  .status-dot-ready { background: #3b82f6; }
1737
1962
  .status-dot-in_progress { background: #f97316; }
1963
+ .status-dot-in_review { background: #8b5cf6; }
1738
1964
  .status-dot-completed { background: #22c55e; }
1739
1965
  .status-dot-failed { background: #ef4444; }
1740
1966
 
@@ -1944,6 +2170,7 @@ function getClientHTML() {
1944
2170
  .status-badge-draft { background: #f5f5f5; color: #737373; }
1945
2171
  .status-badge-ready { background: #eff6ff; color: #3b82f6; }
1946
2172
  .status-badge-in_progress { background: #fff7ed; color: #f97316; }
2173
+ .status-badge-in_review { background: #f5f3ff; color: #8b5cf6; }
1947
2174
  .status-badge-completed { background: #f0fdf4; color: #22c55e; }
1948
2175
  .status-badge-failed { background: #fef2f2; color: #ef4444; }
1949
2176
 
@@ -2269,11 +2496,16 @@ socket.on('task:output', ({ taskId, line }) => {
2269
2496
  }
2270
2497
  });
2271
2498
 
2272
- socket.on('task:completed', ({ taskId }) => {
2499
+ socket.on('task:completed', ({ taskId, status }) => {
2273
2500
  state.running = state.running.filter(id => id !== taskId);
2274
2501
  const task = state.tasks.find(t => t.id === taskId);
2275
- if (task) { task.status = 'completed'; task.passes = true; }
2276
- 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');
2277
2509
  render();
2278
2510
  });
2279
2511
 
@@ -2347,6 +2579,53 @@ async function retryTask(id) {
2347
2579
  });
2348
2580
  }
2349
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
+
2350
2629
  async function generateTask(prompt) {
2351
2630
  state.aiGenerating = true;
2352
2631
  render();
@@ -2550,6 +2829,7 @@ function renderColumn(status, title, tasks) {
2550
2829
  draft: 'To Do',
2551
2830
  ready: 'Ready',
2552
2831
  in_progress: 'In Progress',
2832
+ in_review: 'In Review',
2553
2833
  completed: 'Done',
2554
2834
  failed: 'Failed'
2555
2835
  };
@@ -2915,7 +3195,7 @@ function renderSidePanel() {
2915
3195
  </div>
2916
3196
 
2917
3197
  <!-- Action Buttons -->
2918
- <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">
2919
3199
  \${task.status === 'draft' ? \`
2920
3200
  <button onclick="updateTask('\${task.id}', { status: 'ready' })" class="btn btn-primary px-4 py-2 text-sm">
2921
3201
  \u2192 Move to Ready
@@ -2931,6 +3211,20 @@ function renderSidePanel() {
2931
3211
  \u23F9 Stop Attempt
2932
3212
  </button>
2933
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
+ \` : ''}
2934
3228
  \${task.status === 'failed' ? \`
2935
3229
  <button onclick="retryTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
2936
3230
  \u21BB Retry
@@ -3131,6 +3425,41 @@ function handleStartAFK() {
3131
3425
  render();
3132
3426
  }
3133
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
+
3134
3463
  // Filter tasks based on search query
3135
3464
  function filterTasks(tasks) {
3136
3465
  if (!state.searchQuery) return tasks;
@@ -3192,6 +3521,7 @@ function render() {
3192
3521
  \${renderColumn('draft', 'To Do', filterTasks(state.tasks))}
3193
3522
  \${renderColumn('ready', 'Ready', filterTasks(state.tasks))}
3194
3523
  \${renderColumn('in_progress', 'In Progress', filterTasks(state.tasks))}
3524
+ \${renderColumn('in_review', 'In Review', filterTasks(state.tasks))}
3195
3525
  \${renderColumn('completed', 'Done', filterTasks(state.tasks))}
3196
3526
  \${renderColumn('failed', 'Failed', filterTasks(state.tasks))}
3197
3527
  </div>
@@ -3256,6 +3586,14 @@ window.closeSidePanel = closeSidePanel;
3256
3586
  window.showTaskMenu = showTaskMenu;
3257
3587
  window.filterTasks = filterTasks;
3258
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;
3259
3597
 
3260
3598
  // Keyboard shortcuts
3261
3599
  document.addEventListener('keydown', (e) => {