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.
package/dist/bin/cli.js CHANGED
@@ -4,6 +4,10 @@
4
4
  import { Command } from "commander";
5
5
  import chalk from "chalk";
6
6
  import open from "open";
7
+ import { existsSync as existsSync4 } from "fs";
8
+ import { execSync as execSync2 } from "child_process";
9
+ import { createInterface } from "readline";
10
+ import { join as join6 } from "path";
7
11
 
8
12
  // src/server/index.ts
9
13
  import express from "express";
@@ -16,7 +20,7 @@ import { existsSync as existsSync3 } from "fs";
16
20
  // src/server/services/executor.ts
17
21
  import { spawn, execSync } from "child_process";
18
22
  import { join as join4 } from "path";
19
- import { writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync2, existsSync as existsSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync4, rmSync, symlinkSync } from "fs";
23
+ import { writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync2, existsSync as existsSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync4, rmSync } from "fs";
20
24
  import { EventEmitter } from "events";
21
25
 
22
26
  // src/server/services/project.ts
@@ -454,6 +458,7 @@ function getTaskCounts(projectPath) {
454
458
  draft: 0,
455
459
  ready: 0,
456
460
  in_progress: 0,
461
+ in_review: 0,
457
462
  completed: 0,
458
463
  failed: 0
459
464
  };
@@ -530,6 +535,7 @@ var WORKTREES_DIR = "worktrees";
530
535
  var TaskExecutor = class extends EventEmitter {
531
536
  projectPath;
532
537
  runningTasks = /* @__PURE__ */ new Map();
538
+ reviewingTasks = /* @__PURE__ */ new Map();
533
539
  afkMode = false;
534
540
  afkIteration = 0;
535
541
  afkMaxIterations = 0;
@@ -699,7 +705,6 @@ ${summary}
699
705
  cwd: this.projectPath,
700
706
  stdio: "pipe"
701
707
  });
702
- this.symlinkDependencies(worktreePath);
703
708
  console.log(`[executor] Created worktree at ${worktreePath} on branch ${branchName}`);
704
709
  return { worktreePath, branchName };
705
710
  } catch (error) {
@@ -707,38 +712,6 @@ ${summary}
707
712
  return null;
708
713
  }
709
714
  }
710
- /**
711
- * Symlink dependency directories from main project to worktree
712
- */
713
- symlinkDependencies(worktreePath) {
714
- const depDirs = [
715
- "node_modules",
716
- ".pnpm",
717
- // pnpm store
718
- ".yarn",
719
- // yarn cache
720
- "vendor",
721
- // PHP/Ruby deps
722
- "__pycache__",
723
- // Python cache
724
- ".venv",
725
- // Python virtual env
726
- "venv"
727
- // Python virtual env
728
- ];
729
- for (const dir of depDirs) {
730
- const sourcePath = join4(this.projectPath, dir);
731
- const targetPath = join4(worktreePath, dir);
732
- if (existsSync2(sourcePath) && !existsSync2(targetPath)) {
733
- try {
734
- symlinkSync(sourcePath, targetPath, "junction");
735
- console.log(`[executor] Symlinked ${dir} to worktree`);
736
- } catch (error) {
737
- console.log(`[executor] Could not symlink ${dir}:`, error);
738
- }
739
- }
740
- }
741
- }
742
715
  /**
743
716
  * Remove a git worktree
744
717
  */
@@ -824,7 +797,7 @@ ${summary}
824
797
  /**
825
798
  * Build the prompt for a specific task
826
799
  */
827
- buildTaskPrompt(task, config) {
800
+ buildTaskPrompt(task, config, worktreeInfo) {
828
801
  const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
829
802
  const prdPath = join4(kanbanDir, "prd.json");
830
803
  const progressPath = join4(kanbanDir, "progress.txt");
@@ -838,9 +811,15 @@ ${task.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}` : "";
838
811
  if (config.project.testCommand) {
839
812
  verifySteps.push(`Run tests: ${config.project.testCommand}`);
840
813
  }
841
- const verifySection = verifySteps.length > 0 ? `2. Verify your work:
814
+ const verifySection = verifySteps.length > 0 ? `3. Verify your work:
842
815
  ${verifySteps.map((s) => ` - ${s}`).join("\n")}
843
816
 
817
+ ` : "";
818
+ const worktreeSection = worktreeInfo ? `## ENVIRONMENT
819
+ You are running in an isolated git worktree on branch "${worktreeInfo.branchName}".
820
+ This is a fresh checkout - dependencies (node_modules, vendor, etc.) are NOT installed.
821
+ Before running any build/test commands, install dependencies first (e.g., npm install, composer install, pip install).
822
+
844
823
  ` : "";
845
824
  return `You are an AI coding agent. Complete the following task:
846
825
 
@@ -852,22 +831,24 @@ Priority: ${task.priority}
852
831
  ${task.description}
853
832
  ${stepsText}
854
833
 
855
- ## INSTRUCTIONS
856
- 1. Implement this task as described above.
834
+ ${worktreeSection}## INSTRUCTIONS
835
+ 1. If dependencies are not installed, install them first.
836
+
837
+ 2. Implement this task as described above.
857
838
 
858
- ${verifySection}${verifySteps.length > 0 ? "3" : "2"}. When complete, update the task in ${prdPath}:
839
+ ${verifySection}${verifySteps.length > 0 ? "4" : "3"}. When complete, update the task in ${prdPath}:
859
840
  - Find the task with id "${task.id}"
860
841
  - Set "passes": true
861
- - Set "status": "completed"
842
+ (Note: Do NOT change the status - the system will move it to review)
862
843
 
863
- ${verifySteps.length > 0 ? "4" : "3"}. Document your work in ${progressPath}:
844
+ ${verifySteps.length > 0 ? "5" : "4"}. Document your work in ${progressPath}:
864
845
  - What you implemented and files changed
865
846
  - Key decisions made and why
866
847
  - Gotchas, edge cases, or tricky parts discovered
867
848
  - Useful patterns or approaches that worked well
868
849
  - Anything a future agent should know about this area of the codebase
869
850
 
870
- ${verifySteps.length > 0 ? "5" : "4"}. Make a git commit with a descriptive message.
851
+ ${verifySteps.length > 0 ? "6" : "5"}. Make a git commit with a descriptive message.
871
852
 
872
853
  Focus only on this task. When successfully complete, output: <promise>COMPLETE</promise>`;
873
854
  }
@@ -891,7 +872,7 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
891
872
  const startedAt = /* @__PURE__ */ new Date();
892
873
  const worktreeInfo = this.createWorktree(taskId);
893
874
  const executionPath = worktreeInfo?.worktreePath || this.projectPath;
894
- const prompt = this.buildTaskPrompt(task, config);
875
+ const prompt = this.buildTaskPrompt(task, config, worktreeInfo);
895
876
  const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
896
877
  const promptFile = join4(kanbanDir, `prompt-${taskId}.txt`);
897
878
  writeFileSync4(promptFile, prompt);
@@ -1037,7 +1018,7 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
1037
1018
  }, timeoutMs);
1038
1019
  }
1039
1020
  /**
1040
- * Handle task completion
1021
+ * Handle task completion - moves to in_review, keeps worktree alive
1041
1022
  */
1042
1023
  handleTaskComplete(taskId, exitCode, startedAt) {
1043
1024
  const runningTask = this.runningTasks.get(taskId);
@@ -1049,31 +1030,25 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
1049
1030
  const task = getTaskById(this.projectPath, taskId);
1050
1031
  if (isComplete || exitCode === 0) {
1051
1032
  if (runningTask.worktreePath && runningTask.branchName) {
1052
- const merged = this.mergeWorktreeBranch(taskId);
1053
- if (merged) {
1054
- console.log(`[executor] Successfully merged ${runningTask.branchName}`);
1055
- } else {
1056
- console.log(`[executor] Failed to merge ${runningTask.branchName}, branch preserved for manual merge`);
1057
- }
1058
- this.removeWorktree(taskId);
1033
+ this.reviewingTasks.set(taskId, {
1034
+ worktreePath: runningTask.worktreePath,
1035
+ branchName: runningTask.branchName,
1036
+ startedAt,
1037
+ endedAt
1038
+ });
1039
+ console.log(`[executor] Task ${taskId} ready for review at ${runningTask.worktreePath}`);
1059
1040
  }
1060
1041
  updateTask(this.projectPath, taskId, {
1061
- status: "completed",
1042
+ status: "in_review",
1062
1043
  passes: true
1063
1044
  });
1064
- addExecutionEntry(this.projectPath, taskId, {
1065
- startedAt: startedAt.toISOString(),
1066
- endedAt: endedAt.toISOString(),
1067
- status: "completed",
1068
- duration
1069
- });
1070
1045
  logTaskExecution(this.projectPath, {
1071
1046
  taskId,
1072
1047
  taskTitle: task?.title || "Unknown",
1073
- status: "completed",
1048
+ status: "in_review",
1074
1049
  duration
1075
1050
  });
1076
- this.emit("task:completed", { taskId, duration });
1051
+ this.emit("task:completed", { taskId, duration, status: "in_review" });
1077
1052
  this.afkTasksCompleted++;
1078
1053
  } else {
1079
1054
  if (runningTask.worktreePath) {
@@ -1105,6 +1080,151 @@ Focus only on this task. When successfully complete, output: <promise>COMPLETE</
1105
1080
  this.continueAFKMode();
1106
1081
  }
1107
1082
  }
1083
+ /**
1084
+ * Get info about a task in review
1085
+ */
1086
+ getReviewingTask(taskId) {
1087
+ return this.reviewingTasks.get(taskId);
1088
+ }
1089
+ /**
1090
+ * Merge a reviewed task into the base branch
1091
+ */
1092
+ mergeTask(taskId) {
1093
+ const reviewInfo = this.reviewingTasks.get(taskId);
1094
+ if (!reviewInfo) {
1095
+ return { success: false, error: "Task not in review or no worktree found" };
1096
+ }
1097
+ const merged = this.mergeWorktreeBranch(taskId);
1098
+ if (!merged) {
1099
+ return { success: false, error: "Merge failed - may have conflicts" };
1100
+ }
1101
+ this.removeWorktree(taskId);
1102
+ this.reviewingTasks.delete(taskId);
1103
+ updateTask(this.projectPath, taskId, {
1104
+ status: "completed"
1105
+ });
1106
+ addExecutionEntry(this.projectPath, taskId, {
1107
+ startedAt: reviewInfo.startedAt.toISOString(),
1108
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1109
+ status: "completed",
1110
+ duration: (/* @__PURE__ */ new Date()).getTime() - reviewInfo.startedAt.getTime()
1111
+ });
1112
+ console.log(`[executor] Task ${taskId} merged successfully`);
1113
+ return { success: true };
1114
+ }
1115
+ /**
1116
+ * Create a PR for a reviewed task
1117
+ */
1118
+ createPR(taskId) {
1119
+ const reviewInfo = this.reviewingTasks.get(taskId);
1120
+ const task = getTaskById(this.projectPath, taskId);
1121
+ if (!reviewInfo) {
1122
+ return { success: false, error: "Task not in review or no worktree found" };
1123
+ }
1124
+ try {
1125
+ execSync(`git push -u origin ${reviewInfo.branchName}`, {
1126
+ cwd: this.projectPath,
1127
+ stdio: "pipe"
1128
+ });
1129
+ const prTitle = task?.title || `Task ${taskId}`;
1130
+ const prBody = task?.description || "";
1131
+ const result = execSync(
1132
+ `gh pr create --title "${prTitle.replace(/"/g, '\\"')}" --body "${prBody.replace(/"/g, '\\"')}" --head ${reviewInfo.branchName}`,
1133
+ {
1134
+ cwd: this.projectPath,
1135
+ encoding: "utf-8"
1136
+ }
1137
+ );
1138
+ const prUrl = result.trim();
1139
+ try {
1140
+ execSync(`git worktree remove "${reviewInfo.worktreePath}" --force`, {
1141
+ cwd: this.projectPath,
1142
+ stdio: "pipe"
1143
+ });
1144
+ } catch {
1145
+ }
1146
+ this.reviewingTasks.delete(taskId);
1147
+ updateTask(this.projectPath, taskId, {
1148
+ status: "completed"
1149
+ });
1150
+ addExecutionEntry(this.projectPath, taskId, {
1151
+ startedAt: reviewInfo.startedAt.toISOString(),
1152
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1153
+ status: "completed",
1154
+ duration: (/* @__PURE__ */ new Date()).getTime() - reviewInfo.startedAt.getTime()
1155
+ });
1156
+ console.log(`[executor] PR created for task ${taskId}: ${prUrl}`);
1157
+ return { success: true, prUrl };
1158
+ } catch (error) {
1159
+ const errorMsg = error instanceof Error ? error.message : String(error);
1160
+ console.error(`[executor] Failed to create PR:`, errorMsg);
1161
+ return { success: false, error: errorMsg };
1162
+ }
1163
+ }
1164
+ /**
1165
+ * Discard a reviewed task - delete worktree and return to ready
1166
+ */
1167
+ discardTask(taskId) {
1168
+ const reviewInfo = this.reviewingTasks.get(taskId);
1169
+ if (!reviewInfo) {
1170
+ return { success: false, error: "Task not in review or no worktree found" };
1171
+ }
1172
+ this.removeWorktree(taskId);
1173
+ this.reviewingTasks.delete(taskId);
1174
+ updateTask(this.projectPath, taskId, {
1175
+ status: "ready",
1176
+ passes: false
1177
+ });
1178
+ addExecutionEntry(this.projectPath, taskId, {
1179
+ startedAt: reviewInfo.startedAt.toISOString(),
1180
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1181
+ status: "discarded",
1182
+ duration: (/* @__PURE__ */ new Date()).getTime() - reviewInfo.startedAt.getTime()
1183
+ });
1184
+ console.log(`[executor] Task ${taskId} discarded, returned to ready`);
1185
+ return { success: true };
1186
+ }
1187
+ /**
1188
+ * Get diff for a task in review
1189
+ */
1190
+ getTaskDiff(taskId) {
1191
+ const reviewInfo = this.reviewingTasks.get(taskId);
1192
+ if (!reviewInfo) {
1193
+ return { success: false, error: "Task not in review or no worktree found" };
1194
+ }
1195
+ try {
1196
+ const diff = execSync(`git diff main...${reviewInfo.branchName}`, {
1197
+ cwd: this.projectPath,
1198
+ encoding: "utf-8",
1199
+ maxBuffer: 10 * 1024 * 1024
1200
+ // 10MB buffer for large diffs
1201
+ });
1202
+ return { success: true, diff };
1203
+ } catch (error) {
1204
+ const errorMsg = error instanceof Error ? error.message : String(error);
1205
+ return { success: false, error: errorMsg };
1206
+ }
1207
+ }
1208
+ /**
1209
+ * Get list of changed files for a task in review
1210
+ */
1211
+ getTaskChangedFiles(taskId) {
1212
+ const reviewInfo = this.reviewingTasks.get(taskId);
1213
+ if (!reviewInfo) {
1214
+ return { success: false, error: "Task not in review or no worktree found" };
1215
+ }
1216
+ try {
1217
+ const result = execSync(`git diff --name-only main...${reviewInfo.branchName}`, {
1218
+ cwd: this.projectPath,
1219
+ encoding: "utf-8"
1220
+ });
1221
+ const files = result.trim().split("\n").filter((f) => f);
1222
+ return { success: true, files };
1223
+ } catch (error) {
1224
+ const errorMsg = error instanceof Error ? error.message : String(error);
1225
+ return { success: false, error: errorMsg };
1226
+ }
1227
+ }
1108
1228
  /**
1109
1229
  * Cancel a running task
1110
1230
  */
@@ -1771,6 +1891,90 @@ async function createServer(projectPath, port) {
1771
1891
  res.status(500).json({ error: String(error) });
1772
1892
  }
1773
1893
  });
1894
+ app.post("/api/tasks/:id/merge", (req, res) => {
1895
+ try {
1896
+ const result = executor.mergeTask(req.params.id);
1897
+ if (!result.success) {
1898
+ res.status(400).json({ error: result.error });
1899
+ return;
1900
+ }
1901
+ const task = getTaskById(projectPath, req.params.id);
1902
+ if (task) {
1903
+ io.emit("task:updated", task);
1904
+ }
1905
+ res.json({ success: true });
1906
+ } catch (error) {
1907
+ res.status(500).json({ error: String(error) });
1908
+ }
1909
+ });
1910
+ app.post("/api/tasks/:id/create-pr", (req, res) => {
1911
+ try {
1912
+ const result = executor.createPR(req.params.id);
1913
+ if (!result.success) {
1914
+ res.status(400).json({ error: result.error });
1915
+ return;
1916
+ }
1917
+ const task = getTaskById(projectPath, req.params.id);
1918
+ if (task) {
1919
+ io.emit("task:updated", task);
1920
+ }
1921
+ res.json({ success: true, prUrl: result.prUrl });
1922
+ } catch (error) {
1923
+ res.status(500).json({ error: String(error) });
1924
+ }
1925
+ });
1926
+ app.post("/api/tasks/:id/discard", (req, res) => {
1927
+ try {
1928
+ const result = executor.discardTask(req.params.id);
1929
+ if (!result.success) {
1930
+ res.status(400).json({ error: result.error });
1931
+ return;
1932
+ }
1933
+ const task = getTaskById(projectPath, req.params.id);
1934
+ if (task) {
1935
+ io.emit("task:updated", task);
1936
+ }
1937
+ res.json({ success: true });
1938
+ } catch (error) {
1939
+ res.status(500).json({ error: String(error) });
1940
+ }
1941
+ });
1942
+ app.get("/api/tasks/:id/diff", (req, res) => {
1943
+ try {
1944
+ const result = executor.getTaskDiff(req.params.id);
1945
+ if (!result.success) {
1946
+ res.status(400).json({ error: result.error });
1947
+ return;
1948
+ }
1949
+ res.json({ diff: result.diff });
1950
+ } catch (error) {
1951
+ res.status(500).json({ error: String(error) });
1952
+ }
1953
+ });
1954
+ app.get("/api/tasks/:id/changed-files", (req, res) => {
1955
+ try {
1956
+ const result = executor.getTaskChangedFiles(req.params.id);
1957
+ if (!result.success) {
1958
+ res.status(400).json({ error: result.error });
1959
+ return;
1960
+ }
1961
+ res.json({ files: result.files });
1962
+ } catch (error) {
1963
+ res.status(500).json({ error: String(error) });
1964
+ }
1965
+ });
1966
+ app.get("/api/tasks/:id/review-info", (req, res) => {
1967
+ try {
1968
+ const info = executor.getReviewingTask(req.params.id);
1969
+ if (!info) {
1970
+ res.status(404).json({ error: "Task not in review" });
1971
+ return;
1972
+ }
1973
+ res.json(info);
1974
+ } catch (error) {
1975
+ res.status(500).json({ error: String(error) });
1976
+ }
1977
+ });
1774
1978
  app.get("/api/tasks/:id/logs", (req, res) => {
1775
1979
  try {
1776
1980
  const logs = executor.getTaskLog(req.params.id);
@@ -2080,6 +2284,7 @@ function getClientHTML() {
2080
2284
  .status-dot-draft { background: #a3a3a3; }
2081
2285
  .status-dot-ready { background: #3b82f6; }
2082
2286
  .status-dot-in_progress { background: #f97316; }
2287
+ .status-dot-in_review { background: #8b5cf6; }
2083
2288
  .status-dot-completed { background: #22c55e; }
2084
2289
  .status-dot-failed { background: #ef4444; }
2085
2290
 
@@ -2289,6 +2494,7 @@ function getClientHTML() {
2289
2494
  .status-badge-draft { background: #f5f5f5; color: #737373; }
2290
2495
  .status-badge-ready { background: #eff6ff; color: #3b82f6; }
2291
2496
  .status-badge-in_progress { background: #fff7ed; color: #f97316; }
2497
+ .status-badge-in_review { background: #f5f3ff; color: #8b5cf6; }
2292
2498
  .status-badge-completed { background: #f0fdf4; color: #22c55e; }
2293
2499
  .status-badge-failed { background: #fef2f2; color: #ef4444; }
2294
2500
 
@@ -2614,11 +2820,16 @@ socket.on('task:output', ({ taskId, line }) => {
2614
2820
  }
2615
2821
  });
2616
2822
 
2617
- socket.on('task:completed', ({ taskId }) => {
2823
+ socket.on('task:completed', ({ taskId, status }) => {
2618
2824
  state.running = state.running.filter(id => id !== taskId);
2619
2825
  const task = state.tasks.find(t => t.id === taskId);
2620
- if (task) { task.status = 'completed'; task.passes = true; }
2621
- showToast('Task completed: ' + (task?.title || taskId), 'success');
2826
+ if (task) {
2827
+ // Use status from server (could be 'in_review' or 'completed')
2828
+ task.status = status || 'in_review';
2829
+ task.passes = true;
2830
+ }
2831
+ const statusMsg = (status === 'in_review') ? 'ready for review' : 'completed';
2832
+ showToast('Task ' + statusMsg + ': ' + (task?.title || taskId), 'success');
2622
2833
  render();
2623
2834
  });
2624
2835
 
@@ -2692,6 +2903,53 @@ async function retryTask(id) {
2692
2903
  });
2693
2904
  }
2694
2905
 
2906
+ async function mergeTask(id) {
2907
+ const res = await fetch('/api/tasks/' + id + '/merge', { method: 'POST' });
2908
+ const data = await res.json();
2909
+ if (!res.ok) {
2910
+ throw new Error(data.error || 'Merge failed');
2911
+ }
2912
+ return data;
2913
+ }
2914
+
2915
+ async function createPR(id) {
2916
+ const res = await fetch('/api/tasks/' + id + '/create-pr', { method: 'POST' });
2917
+ const data = await res.json();
2918
+ if (!res.ok) {
2919
+ throw new Error(data.error || 'PR creation failed');
2920
+ }
2921
+ return data;
2922
+ }
2923
+
2924
+ async function discardTask(id) {
2925
+ const res = await fetch('/api/tasks/' + id + '/discard', { method: 'POST' });
2926
+ const data = await res.json();
2927
+ if (!res.ok) {
2928
+ throw new Error(data.error || 'Discard failed');
2929
+ }
2930
+ return data;
2931
+ }
2932
+
2933
+ async function getReviewInfo(id) {
2934
+ const res = await fetch('/api/tasks/' + id + '/review-info');
2935
+ const data = await res.json();
2936
+ if (!res.ok) {
2937
+ throw new Error(data.error || 'Failed to get review info');
2938
+ }
2939
+ return data;
2940
+ }
2941
+
2942
+ async function openInVSCode(id) {
2943
+ try {
2944
+ const info = await getReviewInfo(id);
2945
+ // Open VS Code at the worktree path
2946
+ window.open('vscode://file/' + encodeURIComponent(info.worktreePath), '_blank');
2947
+ showToast('Opening in VS Code...', 'info');
2948
+ } catch (e) {
2949
+ showToast('Failed to open in VS Code: ' + e.message, 'error');
2950
+ }
2951
+ }
2952
+
2695
2953
  async function generateTask(prompt) {
2696
2954
  state.aiGenerating = true;
2697
2955
  render();
@@ -2895,6 +3153,7 @@ function renderColumn(status, title, tasks) {
2895
3153
  draft: 'To Do',
2896
3154
  ready: 'Ready',
2897
3155
  in_progress: 'In Progress',
3156
+ in_review: 'In Review',
2898
3157
  completed: 'Done',
2899
3158
  failed: 'Failed'
2900
3159
  };
@@ -3260,7 +3519,7 @@ function renderSidePanel() {
3260
3519
  </div>
3261
3520
 
3262
3521
  <!-- Action Buttons -->
3263
- <div class="px-5 py-4 border-b border-canvas-200 flex gap-2 flex-shrink-0">
3522
+ <div class="px-5 py-4 border-b border-canvas-200 flex flex-wrap gap-2 flex-shrink-0">
3264
3523
  \${task.status === 'draft' ? \`
3265
3524
  <button onclick="updateTask('\${task.id}', { status: 'ready' })" class="btn btn-primary px-4 py-2 text-sm">
3266
3525
  \u2192 Move to Ready
@@ -3276,6 +3535,20 @@ function renderSidePanel() {
3276
3535
  \u23F9 Stop Attempt
3277
3536
  </button>
3278
3537
  \` : ''}
3538
+ \${task.status === 'in_review' ? \`
3539
+ <button onclick="handleMerge('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
3540
+ \u2713 Merge
3541
+ </button>
3542
+ <button onclick="handleCreatePR('\${task.id}')" class="btn btn-ghost px-4 py-2 text-sm">
3543
+ \u21E1 Create PR
3544
+ </button>
3545
+ <button onclick="openInVSCode('\${task.id}')" class="btn btn-ghost px-4 py-2 text-sm">
3546
+ \u{1F4C2} Open in VS Code
3547
+ </button>
3548
+ <button onclick="handleDiscard('\${task.id}')" class="btn btn-danger px-4 py-2 text-sm">
3549
+ \u2715 Discard
3550
+ </button>
3551
+ \` : ''}
3279
3552
  \${task.status === 'failed' ? \`
3280
3553
  <button onclick="retryTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
3281
3554
  \u21BB Retry
@@ -3476,6 +3749,41 @@ function handleStartAFK() {
3476
3749
  render();
3477
3750
  }
3478
3751
 
3752
+ async function handleMerge(taskId) {
3753
+ if (!confirm('Merge this task into the main branch?')) return;
3754
+ try {
3755
+ await mergeTask(taskId);
3756
+ showToast('Task merged successfully!', 'success');
3757
+ closeSidePanel();
3758
+ } catch (e) {
3759
+ showToast('Merge failed: ' + e.message, 'error');
3760
+ }
3761
+ }
3762
+
3763
+ async function handleCreatePR(taskId) {
3764
+ try {
3765
+ const result = await createPR(taskId);
3766
+ showToast('PR created successfully!', 'success');
3767
+ if (result.prUrl) {
3768
+ window.open(result.prUrl, '_blank');
3769
+ }
3770
+ closeSidePanel();
3771
+ } catch (e) {
3772
+ showToast('PR creation failed: ' + e.message, 'error');
3773
+ }
3774
+ }
3775
+
3776
+ async function handleDiscard(taskId) {
3777
+ if (!confirm('Discard this work? The task will return to Ready status.')) return;
3778
+ try {
3779
+ await discardTask(taskId);
3780
+ showToast('Task discarded, returned to Ready', 'warning');
3781
+ closeSidePanel();
3782
+ } catch (e) {
3783
+ showToast('Discard failed: ' + e.message, 'error');
3784
+ }
3785
+ }
3786
+
3479
3787
  // Filter tasks based on search query
3480
3788
  function filterTasks(tasks) {
3481
3789
  if (!state.searchQuery) return tasks;
@@ -3537,6 +3845,7 @@ function render() {
3537
3845
  \${renderColumn('draft', 'To Do', filterTasks(state.tasks))}
3538
3846
  \${renderColumn('ready', 'Ready', filterTasks(state.tasks))}
3539
3847
  \${renderColumn('in_progress', 'In Progress', filterTasks(state.tasks))}
3848
+ \${renderColumn('in_review', 'In Review', filterTasks(state.tasks))}
3540
3849
  \${renderColumn('completed', 'Done', filterTasks(state.tasks))}
3541
3850
  \${renderColumn('failed', 'Failed', filterTasks(state.tasks))}
3542
3851
  </div>
@@ -3601,6 +3910,14 @@ window.closeSidePanel = closeSidePanel;
3601
3910
  window.showTaskMenu = showTaskMenu;
3602
3911
  window.filterTasks = filterTasks;
3603
3912
  window.scrollSidePanelLog = scrollSidePanelLog;
3913
+ window.mergeTask = mergeTask;
3914
+ window.createPR = createPR;
3915
+ window.discardTask = discardTask;
3916
+ window.handleMerge = handleMerge;
3917
+ window.handleCreatePR = handleCreatePR;
3918
+ window.handleDiscard = handleDiscard;
3919
+ window.openInVSCode = openInVSCode;
3920
+ window.getReviewInfo = getReviewInfo;
3604
3921
 
3605
3922
  // Keyboard shortcuts
3606
3923
  document.addEventListener('keydown', (e) => {
@@ -3675,6 +3992,49 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
3675
3992
  }
3676
3993
 
3677
3994
  // src/bin/cli.ts
3995
+ function isGitRepo(dir) {
3996
+ return existsSync4(join6(dir, ".git"));
3997
+ }
3998
+ function hasGitRemote(dir) {
3999
+ try {
4000
+ const result = execSync2("git remote -v", { cwd: dir, stdio: "pipe" }).toString();
4001
+ return result.trim().length > 0;
4002
+ } catch {
4003
+ return false;
4004
+ }
4005
+ }
4006
+ function detectRemoteType(dir) {
4007
+ try {
4008
+ const result = execSync2("git remote get-url origin", { cwd: dir, stdio: "pipe" }).toString().toLowerCase();
4009
+ if (result.includes("github.com")) return "github";
4010
+ if (result.includes("gitlab.com") || result.includes("gitlab")) return "gitlab";
4011
+ if (result.includes("bitbucket.org") || result.includes("bitbucket")) return "bitbucket";
4012
+ if (result.trim()) return "other";
4013
+ return null;
4014
+ } catch {
4015
+ return null;
4016
+ }
4017
+ }
4018
+ function initializeGit(dir) {
4019
+ execSync2("git init", { cwd: dir, stdio: "pipe" });
4020
+ try {
4021
+ execSync2("git add -A", { cwd: dir, stdio: "pipe" });
4022
+ execSync2('git commit -m "Initial commit"', { cwd: dir, stdio: "pipe" });
4023
+ } catch {
4024
+ }
4025
+ }
4026
+ async function promptYesNo(question) {
4027
+ const rl = createInterface({
4028
+ input: process.stdin,
4029
+ output: process.stdout
4030
+ });
4031
+ return new Promise((resolve) => {
4032
+ rl.question(question, (answer) => {
4033
+ rl.close();
4034
+ resolve(answer.toLowerCase().startsWith("y"));
4035
+ });
4036
+ });
4037
+ }
3678
4038
  var VERSION = "0.1.0";
3679
4039
  var banner = `
3680
4040
  ${chalk.cyan("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557")}
@@ -3687,6 +4047,32 @@ async function main() {
3687
4047
  program.name("claude-kanban").description("Visual Kanban board for AI-powered development with Claude").version(VERSION).option("-p, --port <number>", "Port to run server on", "4242").option("-n, --no-open", "Do not auto-open browser").option("--init", "Re-initialize project files").option("--reset", "Reset all tasks (keeps config)").action(async (options) => {
3688
4048
  console.log(banner);
3689
4049
  const cwd = process.cwd();
4050
+ if (!isGitRepo(cwd)) {
4051
+ console.log(chalk.yellow("\n\u26A0 This directory is not a git repository."));
4052
+ console.log(chalk.gray("Git is required for task isolation and parallel execution.\n"));
4053
+ const shouldInit = await promptYesNo(chalk.white("Would you like to initialize git now? (y/n): "));
4054
+ if (shouldInit) {
4055
+ try {
4056
+ console.log(chalk.gray("Initializing git repository..."));
4057
+ initializeGit(cwd);
4058
+ console.log(chalk.green("\u2713 Git repository initialized"));
4059
+ } catch (error) {
4060
+ console.error(chalk.red("Failed to initialize git:"), error);
4061
+ process.exit(1);
4062
+ }
4063
+ } else {
4064
+ console.log(chalk.red("\nGit is required to run Claude Kanban."));
4065
+ console.log(chalk.gray('Please run "git init" manually and try again.'));
4066
+ process.exit(1);
4067
+ }
4068
+ } else {
4069
+ const remoteType = detectRemoteType(cwd);
4070
+ if (remoteType) {
4071
+ console.log(chalk.gray(`Git remote detected: ${remoteType}`));
4072
+ } else if (!hasGitRemote(cwd)) {
4073
+ console.log(chalk.gray("Git repository (local only, no remote)"));
4074
+ }
4075
+ }
3690
4076
  const initialized = await isProjectInitialized(cwd);
3691
4077
  if (!initialized || options.init) {
3692
4078
  console.log(chalk.yellow("Initializing project..."));