claude-kanban 0.5.1 → 0.6.1

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.
@@ -2,9 +2,9 @@
2
2
  import express from "express";
3
3
  import { createServer as createHttpServer } from "http";
4
4
  import { Server as SocketIOServer } from "socket.io";
5
- import { join as join5, dirname } from "path";
5
+ import { join as join6, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
- import { existsSync as existsSync3 } from "fs";
7
+ import { existsSync as existsSync4 } from "fs";
8
8
 
9
9
  // src/server/services/executor.ts
10
10
  import { spawn } from "child_process";
@@ -67,7 +67,8 @@ function createTask(projectPath, request) {
67
67
  passes: false,
68
68
  createdAt: now,
69
69
  updatedAt: now,
70
- executionHistory: []
70
+ executionHistory: [],
71
+ dependsOn: request.dependsOn || []
71
72
  };
72
73
  prd.tasks.push(task);
73
74
  writePRD(projectPath, prd);
@@ -110,23 +111,40 @@ function addExecutionEntry(projectPath, taskId, entry) {
110
111
  writePRD(projectPath, prd);
111
112
  return prd.tasks[taskIndex];
112
113
  }
114
+ function areDependenciesMet(projectPath, task) {
115
+ if (!task.dependsOn || task.dependsOn.length === 0) {
116
+ return true;
117
+ }
118
+ const prd = readPRD(projectPath);
119
+ for (const depId of task.dependsOn) {
120
+ const depTask = prd.tasks.find((t) => t.id === depId);
121
+ if (!depTask || depTask.status !== "completed") {
122
+ return false;
123
+ }
124
+ }
125
+ return true;
126
+ }
113
127
  function getNextReadyTask(projectPath) {
114
128
  const readyTasks = getTasksByStatus(projectPath, "ready");
115
129
  if (readyTasks.length === 0) {
116
130
  return null;
117
131
  }
132
+ const executableTasks = readyTasks.filter((task) => areDependenciesMet(projectPath, task));
133
+ if (executableTasks.length === 0) {
134
+ return null;
135
+ }
118
136
  const priorityOrder = {
119
137
  critical: 0,
120
138
  high: 1,
121
139
  medium: 2,
122
140
  low: 3
123
141
  };
124
- readyTasks.sort((a, b) => {
142
+ executableTasks.sort((a, b) => {
125
143
  const priorityDiff = (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2);
126
144
  if (priorityDiff !== 0) return priorityDiff;
127
145
  return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
128
146
  });
129
- return readyTasks[0];
147
+ return executableTasks[0];
130
148
  }
131
149
  function getTaskCounts(projectPath) {
132
150
  const tasks = getAllTasks(projectPath);
@@ -210,6 +228,11 @@ var TaskExecutor = class extends EventEmitter {
210
228
  projectPath;
211
229
  runningTask = null;
212
230
  planningSession = null;
231
+ qaSession = null;
232
+ pendingQATaskId = null;
233
+ // Task waiting for QA
234
+ qaRetryCount = /* @__PURE__ */ new Map();
235
+ // Track QA retries per task
213
236
  afkMode = false;
214
237
  afkIteration = 0;
215
238
  afkMaxIterations = 0;
@@ -342,10 +365,16 @@ ${summary}
342
365
  return this.planningSession !== null;
343
366
  }
344
367
  /**
345
- * Check if executor is busy (task or planning running)
368
+ * Check if executor is busy (task, planning, or QA running)
346
369
  */
347
370
  isBusy() {
348
- return this.runningTask !== null || this.planningSession !== null;
371
+ return this.runningTask !== null || this.planningSession !== null || this.qaSession !== null;
372
+ }
373
+ /**
374
+ * Check if QA is running
375
+ */
376
+ isQARunning() {
377
+ return this.qaSession !== null;
349
378
  }
350
379
  /**
351
380
  * Get planning session output
@@ -768,27 +797,52 @@ When done, output: <promise>PLANNING_COMPLETE</promise>`;
768
797
  const endedAt = /* @__PURE__ */ new Date();
769
798
  const duration = endedAt.getTime() - startedAt.getTime();
770
799
  const output = this.runningTask.output.join("");
800
+ const config = getConfig(this.projectPath);
771
801
  const isComplete = output.includes("<promise>COMPLETE</promise>");
772
802
  const task = getTaskById(this.projectPath, taskId);
773
803
  if (isComplete || exitCode === 0) {
774
- updateTask(this.projectPath, taskId, {
775
- status: "completed",
776
- passes: true
777
- });
778
- addExecutionEntry(this.projectPath, taskId, {
779
- startedAt: startedAt.toISOString(),
780
- endedAt: endedAt.toISOString(),
781
- status: "completed",
782
- duration
783
- });
784
- logTaskExecution(this.projectPath, {
785
- taskId,
786
- taskTitle: task?.title || "Unknown",
787
- status: "completed",
788
- duration
789
- });
790
- this.emit("task:completed", { taskId, duration });
791
- this.afkTasksCompleted++;
804
+ if (config.execution.enableQA) {
805
+ updateTask(this.projectPath, taskId, {
806
+ status: "in_progress",
807
+ // Keep in progress until QA passes
808
+ passes: false
809
+ });
810
+ addExecutionEntry(this.projectPath, taskId, {
811
+ startedAt: startedAt.toISOString(),
812
+ endedAt: endedAt.toISOString(),
813
+ status: "completed",
814
+ duration
815
+ });
816
+ this.runningTask = null;
817
+ this.pendingQATaskId = taskId;
818
+ this.runQAVerification(taskId).catch((error) => {
819
+ console.error("QA verification error:", error);
820
+ this.handleQAComplete(taskId, false, ["QA process failed to start"]);
821
+ });
822
+ } else {
823
+ updateTask(this.projectPath, taskId, {
824
+ status: "completed",
825
+ passes: true
826
+ });
827
+ addExecutionEntry(this.projectPath, taskId, {
828
+ startedAt: startedAt.toISOString(),
829
+ endedAt: endedAt.toISOString(),
830
+ status: "completed",
831
+ duration
832
+ });
833
+ logTaskExecution(this.projectPath, {
834
+ taskId,
835
+ taskTitle: task?.title || "Unknown",
836
+ status: "completed",
837
+ duration
838
+ });
839
+ this.emit("task:completed", { taskId, duration });
840
+ this.afkTasksCompleted++;
841
+ this.runningTask = null;
842
+ if (this.afkMode) {
843
+ this.continueAFKMode();
844
+ }
845
+ }
792
846
  } else {
793
847
  updateTask(this.projectPath, taskId, {
794
848
  status: "failed",
@@ -810,12 +864,397 @@ When done, output: <promise>PLANNING_COMPLETE</promise>`;
810
864
  error
811
865
  });
812
866
  this.emit("task:failed", { taskId, error });
867
+ this.runningTask = null;
868
+ if (this.afkMode) {
869
+ this.continueAFKMode();
870
+ }
813
871
  }
814
- this.runningTask = null;
815
- if (this.afkMode) {
872
+ }
873
+ /**
874
+ * Build the QA verification prompt
875
+ */
876
+ buildQAPrompt(task, config) {
877
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
878
+ const prdPath = join4(kanbanDir, "prd.json");
879
+ const stepsText = task.steps.length > 0 ? `
880
+ Verification steps:
881
+ ${task.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}` : "";
882
+ const verifyCommands = [];
883
+ if (config.project.typecheckCommand) {
884
+ verifyCommands.push(config.project.typecheckCommand);
885
+ }
886
+ if (config.project.testCommand) {
887
+ verifyCommands.push(config.project.testCommand);
888
+ }
889
+ return `You are a QA reviewer. Verify that the following task has been correctly implemented.
890
+
891
+ ## TASK TO VERIFY
892
+ Title: ${task.title}
893
+ Category: ${task.category}
894
+ Priority: ${task.priority}
895
+
896
+ ${task.description}
897
+ ${stepsText}
898
+
899
+ ## YOUR JOB
900
+
901
+ 1. Review the recent git commits and changes made for this task.
902
+
903
+ 2. Check that all verification steps (if any) have been satisfied.
904
+
905
+ 3. Run the following quality checks:
906
+ ${verifyCommands.length > 0 ? verifyCommands.map((cmd) => ` - ${cmd}`).join("\n") : " (No verification commands configured)"}
907
+
908
+ 4. Check that the implementation meets the task requirements.
909
+
910
+ 5. If everything passes, output: <qa>PASSED</qa>
911
+
912
+ 6. If there are issues, output: <qa>FAILED</qa>
913
+ Then list the issues that need to be fixed:
914
+ <issues>
915
+ - Issue 1
916
+ - Issue 2
917
+ </issues>
918
+
919
+ Be thorough but fair. Only fail the QA if there are real issues that affect functionality or quality.`;
920
+ }
921
+ /**
922
+ * Run QA verification for a task
923
+ */
924
+ async runQAVerification(taskId) {
925
+ const config = getConfig(this.projectPath);
926
+ const task = getTaskById(this.projectPath, taskId);
927
+ if (!task) {
928
+ throw new Error(`Task not found: ${taskId}`);
929
+ }
930
+ const currentRetries = this.qaRetryCount.get(taskId) || 0;
931
+ const attempt = currentRetries + 1;
932
+ const startedAt = /* @__PURE__ */ new Date();
933
+ const prompt = this.buildQAPrompt(task, config);
934
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
935
+ const promptFile = join4(kanbanDir, `qa-${taskId}.txt`);
936
+ writeFileSync4(promptFile, prompt);
937
+ const args = [];
938
+ if (config.agent.model) {
939
+ args.push("--model", config.agent.model);
940
+ }
941
+ args.push("--permission-mode", config.agent.permissionMode);
942
+ args.push("-p");
943
+ args.push("--verbose");
944
+ args.push("--output-format", "stream-json");
945
+ args.push(`@${promptFile}`);
946
+ const fullCommand = `${config.agent.command} ${args.join(" ")}`;
947
+ console.log("[executor] QA command:", fullCommand);
948
+ const childProcess = spawn("bash", ["-c", fullCommand], {
949
+ cwd: this.projectPath,
950
+ env: {
951
+ ...process.env,
952
+ TERM: "xterm-256color",
953
+ FORCE_COLOR: "0",
954
+ NO_COLOR: "1"
955
+ },
956
+ stdio: ["ignore", "pipe", "pipe"]
957
+ });
958
+ this.qaSession = {
959
+ taskId,
960
+ process: childProcess,
961
+ startedAt,
962
+ output: [],
963
+ attempt
964
+ };
965
+ this.emit("qa:started", { taskId, attempt });
966
+ const logOutput = (line) => {
967
+ this.qaSession?.output.push(line);
968
+ this.emit("qa:output", { taskId, line });
969
+ };
970
+ logOutput(`[claude-kanban] Starting QA verification (attempt ${attempt})
971
+ `);
972
+ let stdoutBuffer = "";
973
+ childProcess.stdout?.on("data", (data) => {
974
+ stdoutBuffer += data.toString();
975
+ const lines = stdoutBuffer.split("\n");
976
+ stdoutBuffer = lines.pop() || "";
977
+ for (const line of lines) {
978
+ if (!line.trim()) continue;
979
+ try {
980
+ const json = JSON.parse(line);
981
+ let text = "";
982
+ if (json.type === "assistant" && json.message?.content) {
983
+ for (const block of json.message.content) {
984
+ if (block.type === "text") {
985
+ text += block.text;
986
+ } else if (block.type === "tool_use") {
987
+ text += this.formatToolUse(block.name, block.input);
988
+ }
989
+ }
990
+ } else if (json.type === "content_block_delta" && json.delta?.text) {
991
+ text = json.delta.text;
992
+ }
993
+ if (text) {
994
+ logOutput(text);
995
+ }
996
+ } catch {
997
+ const cleanText = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
998
+ if (cleanText.trim()) {
999
+ logOutput(cleanText + "\n");
1000
+ }
1001
+ }
1002
+ }
1003
+ });
1004
+ childProcess.stderr?.on("data", (data) => {
1005
+ const text = data.toString();
1006
+ logOutput(`[stderr] ${text}`);
1007
+ });
1008
+ childProcess.on("error", (error) => {
1009
+ console.log("[executor] QA spawn error:", error.message);
1010
+ try {
1011
+ unlinkSync(promptFile);
1012
+ } catch {
1013
+ }
1014
+ this.handleQAComplete(taskId, false, [`QA process error: ${error.message}`]);
1015
+ });
1016
+ childProcess.on("close", (code) => {
1017
+ console.log("[executor] QA process closed with code:", code);
1018
+ try {
1019
+ unlinkSync(promptFile);
1020
+ } catch {
1021
+ }
1022
+ this.parseQAResult(taskId);
1023
+ });
1024
+ const timeoutMs = 5 * 60 * 1e3;
1025
+ setTimeout(() => {
1026
+ if (this.qaSession?.taskId === taskId) {
1027
+ this.cancelQA("QA timeout exceeded");
1028
+ }
1029
+ }, timeoutMs);
1030
+ }
1031
+ /**
1032
+ * Parse QA result from output
1033
+ */
1034
+ parseQAResult(taskId) {
1035
+ if (!this.qaSession || this.qaSession.taskId !== taskId) return;
1036
+ const output = this.qaSession.output.join("");
1037
+ const passedMatch = output.includes("<qa>PASSED</qa>");
1038
+ const failedMatch = output.includes("<qa>FAILED</qa>");
1039
+ if (passedMatch) {
1040
+ this.handleQAComplete(taskId, true, []);
1041
+ } else if (failedMatch) {
1042
+ const issuesMatch = output.match(/<issues>([\s\S]*?)<\/issues>/);
1043
+ const issues = [];
1044
+ if (issuesMatch) {
1045
+ const issueLines = issuesMatch[1].split("\n");
1046
+ for (const line of issueLines) {
1047
+ const trimmed = line.trim();
1048
+ if (trimmed.startsWith("-")) {
1049
+ issues.push(trimmed.substring(1).trim());
1050
+ } else if (trimmed) {
1051
+ issues.push(trimmed);
1052
+ }
1053
+ }
1054
+ }
1055
+ this.handleQAComplete(taskId, false, issues.length > 0 ? issues : ["QA verification failed"]);
1056
+ } else {
1057
+ this.handleQAComplete(taskId, false, ["QA did not provide a clear PASSED/FAILED result"]);
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Handle QA completion
1062
+ */
1063
+ handleQAComplete(taskId, passed, issues) {
1064
+ const config = getConfig(this.projectPath);
1065
+ const task = getTaskById(this.projectPath, taskId);
1066
+ const currentRetries = this.qaRetryCount.get(taskId) || 0;
1067
+ this.qaSession = null;
1068
+ this.pendingQATaskId = null;
1069
+ if (passed) {
1070
+ updateTask(this.projectPath, taskId, {
1071
+ status: "completed",
1072
+ passes: true
1073
+ });
1074
+ logTaskExecution(this.projectPath, {
1075
+ taskId,
1076
+ taskTitle: task?.title || "Unknown",
1077
+ status: "completed",
1078
+ duration: 0
1079
+ });
1080
+ this.emit("qa:passed", { taskId });
1081
+ this.emit("task:completed", { taskId, duration: 0 });
1082
+ this.afkTasksCompleted++;
1083
+ this.qaRetryCount.delete(taskId);
1084
+ } else {
1085
+ const willRetry = currentRetries < config.execution.qaMaxRetries;
1086
+ this.emit("qa:failed", { taskId, issues, willRetry });
1087
+ if (willRetry) {
1088
+ this.qaRetryCount.set(taskId, currentRetries + 1);
1089
+ console.log(`[executor] QA failed, retrying (${currentRetries + 1}/${config.execution.qaMaxRetries})`);
1090
+ this.runTaskWithQAFix(taskId, issues).catch((error) => {
1091
+ console.error("QA fix error:", error);
1092
+ this.handleQAComplete(taskId, false, ["Failed to run QA fix"]);
1093
+ });
1094
+ } else {
1095
+ updateTask(this.projectPath, taskId, {
1096
+ status: "failed",
1097
+ passes: false
1098
+ });
1099
+ logTaskExecution(this.projectPath, {
1100
+ taskId,
1101
+ taskTitle: task?.title || "Unknown",
1102
+ status: "failed",
1103
+ duration: 0,
1104
+ error: `QA failed after ${config.execution.qaMaxRetries} retries: ${issues.join(", ")}`
1105
+ });
1106
+ this.emit("task:failed", { taskId, error: `QA failed: ${issues.join(", ")}` });
1107
+ this.qaRetryCount.delete(taskId);
1108
+ }
1109
+ }
1110
+ if (this.afkMode && !this.isBusy()) {
816
1111
  this.continueAFKMode();
817
1112
  }
818
1113
  }
1114
+ /**
1115
+ * Run task again with QA fix instructions
1116
+ */
1117
+ async runTaskWithQAFix(taskId, issues) {
1118
+ const config = getConfig(this.projectPath);
1119
+ const task = getTaskById(this.projectPath, taskId);
1120
+ if (!task) {
1121
+ throw new Error(`Task not found: ${taskId}`);
1122
+ }
1123
+ const startedAt = /* @__PURE__ */ new Date();
1124
+ const issuesText = issues.map((i, idx) => `${idx + 1}. ${i}`).join("\n");
1125
+ const prompt = `You are fixing issues found during QA verification for a task.
1126
+
1127
+ ## ORIGINAL TASK
1128
+ Title: ${task.title}
1129
+ ${task.description}
1130
+
1131
+ ## QA ISSUES TO FIX
1132
+ ${issuesText}
1133
+
1134
+ ## INSTRUCTIONS
1135
+ 1. Review the issues reported by QA.
1136
+ 2. Fix each issue.
1137
+ 3. Run verification commands to ensure fixes work.
1138
+ 4. When done, output: <promise>COMPLETE</promise>
1139
+
1140
+ Focus only on fixing the reported issues, don't make unrelated changes.`;
1141
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
1142
+ const promptFile = join4(kanbanDir, `qafix-${taskId}.txt`);
1143
+ writeFileSync4(promptFile, prompt);
1144
+ const args = [];
1145
+ if (config.agent.model) {
1146
+ args.push("--model", config.agent.model);
1147
+ }
1148
+ args.push("--permission-mode", config.agent.permissionMode);
1149
+ args.push("-p");
1150
+ args.push("--verbose");
1151
+ args.push("--output-format", "stream-json");
1152
+ args.push(`@${promptFile}`);
1153
+ const fullCommand = `${config.agent.command} ${args.join(" ")}`;
1154
+ const childProcess = spawn("bash", ["-c", fullCommand], {
1155
+ cwd: this.projectPath,
1156
+ env: {
1157
+ ...process.env,
1158
+ TERM: "xterm-256color",
1159
+ FORCE_COLOR: "0",
1160
+ NO_COLOR: "1"
1161
+ },
1162
+ stdio: ["ignore", "pipe", "pipe"]
1163
+ });
1164
+ this.runningTask = {
1165
+ taskId,
1166
+ process: childProcess,
1167
+ startedAt,
1168
+ output: []
1169
+ };
1170
+ this.emit("task:started", { taskId, timestamp: startedAt.toISOString() });
1171
+ let stdoutBuffer = "";
1172
+ childProcess.stdout?.on("data", (data) => {
1173
+ stdoutBuffer += data.toString();
1174
+ const lines = stdoutBuffer.split("\n");
1175
+ stdoutBuffer = lines.pop() || "";
1176
+ for (const line of lines) {
1177
+ if (!line.trim()) continue;
1178
+ try {
1179
+ const json = JSON.parse(line);
1180
+ let text = "";
1181
+ if (json.type === "assistant" && json.message?.content) {
1182
+ for (const block of json.message.content) {
1183
+ if (block.type === "text") {
1184
+ text += block.text;
1185
+ } else if (block.type === "tool_use") {
1186
+ text += this.formatToolUse(block.name, block.input);
1187
+ }
1188
+ }
1189
+ } else if (json.type === "content_block_delta" && json.delta?.text) {
1190
+ text = json.delta.text;
1191
+ }
1192
+ if (text) {
1193
+ this.runningTask?.output.push(text);
1194
+ this.emit("task:output", { taskId, line: text, lineType: "stdout" });
1195
+ }
1196
+ } catch {
1197
+ const cleanText = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
1198
+ if (cleanText.trim()) {
1199
+ this.runningTask?.output.push(cleanText + "\n");
1200
+ this.emit("task:output", { taskId, line: cleanText + "\n", lineType: "stdout" });
1201
+ }
1202
+ }
1203
+ }
1204
+ });
1205
+ childProcess.stderr?.on("data", (data) => {
1206
+ const text = data.toString();
1207
+ this.runningTask?.output.push(`[stderr] ${text}`);
1208
+ this.emit("task:output", { taskId, line: `[stderr] ${text}`, lineType: "stderr" });
1209
+ });
1210
+ childProcess.on("error", (error) => {
1211
+ try {
1212
+ unlinkSync(promptFile);
1213
+ } catch {
1214
+ }
1215
+ this.handleQAComplete(taskId, false, [`Fix process error: ${error.message}`]);
1216
+ });
1217
+ childProcess.on("close", (code) => {
1218
+ try {
1219
+ unlinkSync(promptFile);
1220
+ } catch {
1221
+ }
1222
+ const output = this.runningTask?.output.join("") || "";
1223
+ const isComplete = output.includes("<promise>COMPLETE</promise>");
1224
+ this.runningTask = null;
1225
+ if (isComplete || code === 0) {
1226
+ this.runQAVerification(taskId).catch((error) => {
1227
+ console.error("QA verification error after fix:", error);
1228
+ this.handleQAComplete(taskId, false, ["QA verification failed after fix"]);
1229
+ });
1230
+ } else {
1231
+ this.handleQAComplete(taskId, false, [`Fix process exited with code ${code}`]);
1232
+ }
1233
+ });
1234
+ }
1235
+ /**
1236
+ * Cancel QA verification
1237
+ */
1238
+ cancelQA(reason = "Cancelled") {
1239
+ if (!this.qaSession) return false;
1240
+ const { taskId, process: childProcess } = this.qaSession;
1241
+ try {
1242
+ childProcess.kill("SIGTERM");
1243
+ setTimeout(() => {
1244
+ try {
1245
+ if (!childProcess.killed) {
1246
+ childProcess.kill("SIGKILL");
1247
+ }
1248
+ } catch {
1249
+ }
1250
+ }, 2e3);
1251
+ } catch {
1252
+ }
1253
+ this.emit("qa:failed", { taskId, issues: [reason], willRetry: false });
1254
+ this.qaSession = null;
1255
+ this.pendingQATaskId = null;
1256
+ return true;
1257
+ }
819
1258
  /**
820
1259
  * Cancel the running task
821
1260
  */
@@ -888,60 +1327,509 @@ When done, output: <promise>PLANNING_COMPLETE</promise>`;
888
1327
  this.stopAFKMode();
889
1328
  return;
890
1329
  }
891
- this.afkIteration++;
892
- this.runTask(nextTask.id).catch((error) => {
893
- console.error("AFK task error:", error);
894
- });
895
- this.emitAFKStatus();
1330
+ this.afkIteration++;
1331
+ this.runTask(nextTask.id).catch((error) => {
1332
+ console.error("AFK task error:", error);
1333
+ });
1334
+ this.emitAFKStatus();
1335
+ }
1336
+ /**
1337
+ * Stop AFK mode
1338
+ */
1339
+ stopAFKMode() {
1340
+ this.afkMode = false;
1341
+ this.emitAFKStatus();
1342
+ }
1343
+ /**
1344
+ * Emit AFK status
1345
+ */
1346
+ emitAFKStatus() {
1347
+ this.emit("afk:status", {
1348
+ running: this.afkMode,
1349
+ currentIteration: this.afkIteration,
1350
+ maxIterations: this.afkMaxIterations,
1351
+ tasksCompleted: this.afkTasksCompleted
1352
+ });
1353
+ }
1354
+ /**
1355
+ * Get AFK status
1356
+ */
1357
+ getAFKStatus() {
1358
+ return {
1359
+ running: this.afkMode,
1360
+ currentIteration: this.afkIteration,
1361
+ maxIterations: this.afkMaxIterations,
1362
+ tasksCompleted: this.afkTasksCompleted
1363
+ };
1364
+ }
1365
+ /**
1366
+ * Cancel running task/planning/QA and stop AFK mode
1367
+ */
1368
+ cancelAll() {
1369
+ if (this.runningTask) {
1370
+ try {
1371
+ this.runningTask.process.kill("SIGKILL");
1372
+ } catch {
1373
+ }
1374
+ this.runningTask = null;
1375
+ }
1376
+ if (this.planningSession) {
1377
+ try {
1378
+ this.planningSession.process.kill("SIGKILL");
1379
+ } catch {
1380
+ }
1381
+ this.planningSession = null;
1382
+ }
1383
+ if (this.qaSession) {
1384
+ try {
1385
+ this.qaSession.process.kill("SIGKILL");
1386
+ } catch {
1387
+ }
1388
+ this.qaSession = null;
1389
+ }
1390
+ this.pendingQATaskId = null;
1391
+ this.qaRetryCount.clear();
1392
+ this.stopAFKMode();
1393
+ }
1394
+ };
1395
+
1396
+ // src/server/services/roadmap.ts
1397
+ import { spawn as spawn2 } from "child_process";
1398
+ import { EventEmitter as EventEmitter2 } from "events";
1399
+ import { existsSync as existsSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync3 } from "fs";
1400
+ import { join as join5, basename as basename2 } from "path";
1401
+ import { nanoid as nanoid2 } from "nanoid";
1402
+ var ROADMAP_DIR = ".claude-kanban";
1403
+ var ROADMAP_FILE = "roadmap.json";
1404
+ var RoadmapService = class extends EventEmitter2 {
1405
+ projectPath;
1406
+ session = null;
1407
+ config = null;
1408
+ constructor(projectPath) {
1409
+ super();
1410
+ this.projectPath = projectPath;
1411
+ this.loadConfig();
1412
+ }
1413
+ loadConfig() {
1414
+ const configPath = join5(this.projectPath, ROADMAP_DIR, "config.json");
1415
+ if (existsSync3(configPath)) {
1416
+ this.config = JSON.parse(readFileSync5(configPath, "utf-8"));
1417
+ }
1418
+ }
1419
+ /**
1420
+ * Check if roadmap generation is in progress
1421
+ */
1422
+ isRunning() {
1423
+ return this.session !== null;
1424
+ }
1425
+ /**
1426
+ * Get existing roadmap if it exists
1427
+ */
1428
+ getRoadmap() {
1429
+ const roadmapPath = join5(this.projectPath, ROADMAP_DIR, ROADMAP_FILE);
1430
+ if (!existsSync3(roadmapPath)) {
1431
+ return null;
1432
+ }
1433
+ try {
1434
+ return JSON.parse(readFileSync5(roadmapPath, "utf-8"));
1435
+ } catch {
1436
+ return null;
1437
+ }
1438
+ }
1439
+ /**
1440
+ * Save roadmap to file
1441
+ */
1442
+ saveRoadmap(roadmap) {
1443
+ const roadmapDir = join5(this.projectPath, ROADMAP_DIR);
1444
+ if (!existsSync3(roadmapDir)) {
1445
+ mkdirSync3(roadmapDir, { recursive: true });
1446
+ }
1447
+ const roadmapPath = join5(roadmapDir, ROADMAP_FILE);
1448
+ writeFileSync5(roadmapPath, JSON.stringify(roadmap, null, 2));
1449
+ }
1450
+ /**
1451
+ * Generate a new roadmap using Claude
1452
+ */
1453
+ async generate(request = {}) {
1454
+ if (this.session) {
1455
+ throw new Error("Roadmap generation already in progress");
1456
+ }
1457
+ this.emit("roadmap:started", {
1458
+ type: "roadmap:started",
1459
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1460
+ });
1461
+ try {
1462
+ this.emit("roadmap:progress", {
1463
+ type: "roadmap:progress",
1464
+ phase: "analyzing",
1465
+ message: "Analyzing project structure..."
1466
+ });
1467
+ const projectInfo = await this.analyzeProject();
1468
+ let competitors;
1469
+ if (request.enableCompetitorResearch) {
1470
+ this.emit("roadmap:progress", {
1471
+ type: "roadmap:progress",
1472
+ phase: "researching",
1473
+ message: "Researching competitors..."
1474
+ });
1475
+ competitors = await this.researchCompetitors(projectInfo);
1476
+ }
1477
+ this.emit("roadmap:progress", {
1478
+ type: "roadmap:progress",
1479
+ phase: "generating",
1480
+ message: "Generating feature roadmap..."
1481
+ });
1482
+ const roadmap = await this.generateRoadmap(projectInfo, competitors, request);
1483
+ this.saveRoadmap(roadmap);
1484
+ this.emit("roadmap:completed", {
1485
+ type: "roadmap:completed",
1486
+ roadmap
1487
+ });
1488
+ } catch (error) {
1489
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1490
+ this.emit("roadmap:failed", {
1491
+ type: "roadmap:failed",
1492
+ error: errorMessage
1493
+ });
1494
+ throw error;
1495
+ } finally {
1496
+ this.session = null;
1497
+ }
1498
+ }
1499
+ /**
1500
+ * Cancel ongoing roadmap generation
1501
+ */
1502
+ cancel() {
1503
+ if (this.session) {
1504
+ this.session.process.kill("SIGTERM");
1505
+ this.session = null;
1506
+ }
1507
+ }
1508
+ /**
1509
+ * Analyze the project structure
1510
+ */
1511
+ async analyzeProject() {
1512
+ const projectName = basename2(this.projectPath);
1513
+ let description = "";
1514
+ let stack = [];
1515
+ let existingFeatures = [];
1516
+ const packageJsonPath = join5(this.projectPath, "package.json");
1517
+ if (existsSync3(packageJsonPath)) {
1518
+ try {
1519
+ const pkg = JSON.parse(readFileSync5(packageJsonPath, "utf-8"));
1520
+ description = pkg.description || "";
1521
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1522
+ if (deps.react) stack.push("React");
1523
+ if (deps.vue) stack.push("Vue");
1524
+ if (deps.angular) stack.push("Angular");
1525
+ if (deps.next) stack.push("Next.js");
1526
+ if (deps.express) stack.push("Express");
1527
+ if (deps.fastify) stack.push("Fastify");
1528
+ if (deps.typescript) stack.push("TypeScript");
1529
+ if (deps.tailwindcss) stack.push("Tailwind CSS");
1530
+ } catch {
1531
+ }
1532
+ }
1533
+ const readmePaths = ["README.md", "readme.md", "README.txt", "readme.txt"];
1534
+ for (const readmePath of readmePaths) {
1535
+ const fullPath = join5(this.projectPath, readmePath);
1536
+ if (existsSync3(fullPath)) {
1537
+ const readme = readFileSync5(fullPath, "utf-8");
1538
+ if (!description) {
1539
+ const lines = readme.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
1540
+ if (lines.length > 0) {
1541
+ description = lines[0].trim().slice(0, 500);
1542
+ }
1543
+ }
1544
+ break;
1545
+ }
1546
+ }
1547
+ const prdPath = join5(this.projectPath, ROADMAP_DIR, "prd.json");
1548
+ if (existsSync3(prdPath)) {
1549
+ try {
1550
+ const prd = JSON.parse(readFileSync5(prdPath, "utf-8"));
1551
+ existingFeatures = prd.tasks?.map((t) => t.title) || [];
1552
+ } catch {
1553
+ }
1554
+ }
1555
+ return {
1556
+ name: projectName,
1557
+ description,
1558
+ stack,
1559
+ existingFeatures
1560
+ };
1561
+ }
1562
+ /**
1563
+ * Research competitors using Claude with web search
1564
+ */
1565
+ async researchCompetitors(projectInfo) {
1566
+ const prompt = this.buildCompetitorResearchPrompt(projectInfo);
1567
+ const result = await this.runClaudeCommand(prompt);
1568
+ try {
1569
+ const jsonMatch = result.match(/```json\n([\s\S]*?)\n```/);
1570
+ if (jsonMatch) {
1571
+ return JSON.parse(jsonMatch[1]);
1572
+ }
1573
+ return JSON.parse(result);
1574
+ } catch {
1575
+ console.error("[roadmap] Failed to parse competitor research:", result);
1576
+ return [];
1577
+ }
1578
+ }
1579
+ /**
1580
+ * Generate the roadmap using Claude
1581
+ */
1582
+ async generateRoadmap(projectInfo, competitors, request) {
1583
+ const prompt = this.buildRoadmapPrompt(projectInfo, competitors, request);
1584
+ const result = await this.runClaudeCommand(prompt);
1585
+ try {
1586
+ const jsonMatch = result.match(/```json\n([\s\S]*?)\n```/);
1587
+ let roadmapData;
1588
+ if (jsonMatch) {
1589
+ roadmapData = JSON.parse(jsonMatch[1]);
1590
+ } else {
1591
+ roadmapData = JSON.parse(result);
1592
+ }
1593
+ const roadmap = {
1594
+ id: `roadmap_${nanoid2(8)}`,
1595
+ projectName: projectInfo.name,
1596
+ projectDescription: roadmapData.projectDescription || projectInfo.description,
1597
+ targetAudience: roadmapData.targetAudience || "Developers",
1598
+ phases: roadmapData.phases.map((p, i) => ({
1599
+ id: `phase_${nanoid2(8)}`,
1600
+ name: p.name,
1601
+ description: p.description,
1602
+ order: i
1603
+ })),
1604
+ features: roadmapData.features.map((f) => ({
1605
+ id: `feature_${nanoid2(8)}`,
1606
+ title: f.title,
1607
+ description: f.description,
1608
+ priority: f.priority,
1609
+ category: f.category || "functional",
1610
+ effort: f.effort || "medium",
1611
+ impact: f.impact || "medium",
1612
+ phase: f.phase,
1613
+ rationale: f.rationale || "",
1614
+ steps: f.steps,
1615
+ addedToKanban: false
1616
+ })),
1617
+ competitors,
1618
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1619
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1620
+ };
1621
+ return roadmap;
1622
+ } catch (error) {
1623
+ console.error("[roadmap] Failed to parse roadmap:", result);
1624
+ throw new Error("Failed to parse roadmap from Claude response");
1625
+ }
896
1626
  }
897
1627
  /**
898
- * Stop AFK mode
1628
+ * Build the competitor research prompt
899
1629
  */
900
- stopAFKMode() {
901
- this.afkMode = false;
902
- this.emitAFKStatus();
1630
+ buildCompetitorResearchPrompt(projectInfo) {
1631
+ return `You are a product research analyst. Research competitors for the following project:
1632
+
1633
+ Project: ${projectInfo.name}
1634
+ Description: ${projectInfo.description}
1635
+ Tech Stack: ${projectInfo.stack.join(", ") || "Unknown"}
1636
+
1637
+ Your task:
1638
+ 1. Use web search to find 3-5 competitors or similar projects
1639
+ 2. Analyze their strengths and weaknesses
1640
+ 3. Identify differentiating features
1641
+
1642
+ Return your findings as JSON in this format:
1643
+ \`\`\`json
1644
+ [
1645
+ {
1646
+ "name": "Competitor Name",
1647
+ "url": "https://example.com",
1648
+ "strengths": ["strength 1", "strength 2"],
1649
+ "weaknesses": ["weakness 1", "weakness 2"],
1650
+ "differentiators": ["feature 1", "feature 2"]
1651
+ }
1652
+ ]
1653
+ \`\`\`
1654
+
1655
+ Only return the JSON, no other text.`;
903
1656
  }
904
1657
  /**
905
- * Emit AFK status
1658
+ * Build the roadmap generation prompt
906
1659
  */
907
- emitAFKStatus() {
908
- this.emit("afk:status", {
909
- running: this.afkMode,
910
- currentIteration: this.afkIteration,
911
- maxIterations: this.afkMaxIterations,
912
- tasksCompleted: this.afkTasksCompleted
913
- });
1660
+ buildRoadmapPrompt(projectInfo, competitors, request) {
1661
+ let prompt = `You are a product strategist creating a feature roadmap for a software project.
1662
+
1663
+ ## Project Information
1664
+ Name: ${projectInfo.name}
1665
+ Description: ${projectInfo.description}
1666
+ Tech Stack: ${projectInfo.stack.join(", ") || "Unknown"}
1667
+ ${projectInfo.existingFeatures.length > 0 ? `
1668
+ Existing Features/Tasks:
1669
+ ${projectInfo.existingFeatures.map((f) => `- ${f}`).join("\n")}` : ""}
1670
+ `;
1671
+ if (competitors && competitors.length > 0) {
1672
+ prompt += `
1673
+ ## Competitor Analysis
1674
+ ${competitors.map((c) => `
1675
+ ### ${c.name}
1676
+ - Strengths: ${c.strengths.join(", ")}
1677
+ - Weaknesses: ${c.weaknesses.join(", ")}
1678
+ - Key Features: ${c.differentiators.join(", ")}
1679
+ `).join("\n")}
1680
+ `;
1681
+ }
1682
+ if (request.focusAreas && request.focusAreas.length > 0) {
1683
+ prompt += `
1684
+ ## Focus Areas
1685
+ The user wants to focus on: ${request.focusAreas.join(", ")}
1686
+ `;
1687
+ }
1688
+ if (request.customPrompt) {
1689
+ prompt += `
1690
+ ## Additional Context
1691
+ ${request.customPrompt}
1692
+ `;
1693
+ }
1694
+ prompt += `
1695
+ ## Your Task
1696
+ Create a comprehensive product roadmap with features organized into phases.
1697
+
1698
+ Use the MoSCoW prioritization framework:
1699
+ - must: Critical features that must be implemented
1700
+ - should: Important features that should be implemented
1701
+ - could: Nice-to-have features that could be implemented
1702
+ - wont: Features that won't be implemented in the near term
1703
+
1704
+ For each feature, estimate:
1705
+ - effort: low, medium, or high
1706
+ - impact: low, medium, or high
1707
+
1708
+ Categories should be one of: functional, ui, bug, enhancement, testing, refactor
1709
+
1710
+ Return your roadmap as JSON:
1711
+ \`\`\`json
1712
+ {
1713
+ "projectDescription": "Brief description of the project",
1714
+ "targetAudience": "Who this project is for",
1715
+ "phases": [
1716
+ {
1717
+ "name": "Phase 1: MVP",
1718
+ "description": "Core features for minimum viable product"
1719
+ },
1720
+ {
1721
+ "name": "Phase 2: Enhancement",
1722
+ "description": "Features to improve user experience"
1723
+ }
1724
+ ],
1725
+ "features": [
1726
+ {
1727
+ "title": "Feature title",
1728
+ "description": "What this feature does",
1729
+ "priority": "must",
1730
+ "category": "functional",
1731
+ "effort": "medium",
1732
+ "impact": "high",
1733
+ "phase": "Phase 1: MVP",
1734
+ "rationale": "Why this feature is important",
1735
+ "steps": ["Step 1", "Step 2"]
1736
+ }
1737
+ ]
1738
+ }
1739
+ \`\`\`
1740
+
1741
+ Generate 10-20 strategic features across 3-4 phases. Be specific and actionable.
1742
+ Don't duplicate features that already exist in the project.
1743
+ Only return the JSON, no other text.`;
1744
+ return prompt;
914
1745
  }
915
1746
  /**
916
- * Get AFK status
1747
+ * Run a Claude command and return the output
917
1748
  */
918
- getAFKStatus() {
919
- return {
920
- running: this.afkMode,
921
- currentIteration: this.afkIteration,
922
- maxIterations: this.afkMaxIterations,
923
- tasksCompleted: this.afkTasksCompleted
924
- };
1749
+ runClaudeCommand(prompt) {
1750
+ return new Promise((resolve, reject) => {
1751
+ const command = this.config?.agent.command || "claude";
1752
+ const permissionMode = this.config?.agent.permissionMode || "auto";
1753
+ const model = this.config?.agent.model;
1754
+ const promptPath = join5(this.projectPath, ROADMAP_DIR, "prompt-roadmap.txt");
1755
+ writeFileSync5(promptPath, prompt);
1756
+ const args = [
1757
+ "--permission-mode",
1758
+ permissionMode,
1759
+ "-p",
1760
+ "--verbose",
1761
+ "--output-format",
1762
+ "text",
1763
+ `@${promptPath}`
1764
+ ];
1765
+ if (model) {
1766
+ args.unshift("--model", model);
1767
+ }
1768
+ console.log(`[roadmap] Command: ${command} ${args.join(" ")}`);
1769
+ const childProcess = spawn2(command, args, {
1770
+ cwd: this.projectPath,
1771
+ stdio: ["pipe", "pipe", "pipe"],
1772
+ env: {
1773
+ ...process.env,
1774
+ FORCE_COLOR: "0"
1775
+ }
1776
+ });
1777
+ this.session = {
1778
+ process: childProcess,
1779
+ startedAt: /* @__PURE__ */ new Date(),
1780
+ output: []
1781
+ };
1782
+ let stdout = "";
1783
+ let stderr = "";
1784
+ childProcess.stdout?.on("data", (data) => {
1785
+ const chunk = data.toString();
1786
+ stdout += chunk;
1787
+ this.session?.output.push(chunk);
1788
+ });
1789
+ childProcess.stderr?.on("data", (data) => {
1790
+ stderr += data.toString();
1791
+ });
1792
+ childProcess.on("close", (code) => {
1793
+ this.session = null;
1794
+ if (code === 0) {
1795
+ resolve(stdout);
1796
+ } else {
1797
+ reject(new Error(`Claude command failed with code ${code}: ${stderr}`));
1798
+ }
1799
+ });
1800
+ childProcess.on("error", (error) => {
1801
+ this.session = null;
1802
+ reject(error);
1803
+ });
1804
+ });
925
1805
  }
926
1806
  /**
927
- * Cancel running task/planning and stop AFK mode
1807
+ * Mark a feature as added to kanban
928
1808
  */
929
- cancelAll() {
930
- if (this.runningTask) {
931
- try {
932
- this.runningTask.process.kill("SIGKILL");
933
- } catch {
934
- }
935
- this.runningTask = null;
1809
+ markFeatureAdded(featureId) {
1810
+ const roadmap = this.getRoadmap();
1811
+ if (!roadmap) return null;
1812
+ const feature = roadmap.features.find((f) => f.id === featureId);
1813
+ if (feature) {
1814
+ feature.addedToKanban = true;
1815
+ roadmap.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1816
+ this.saveRoadmap(roadmap);
936
1817
  }
937
- if (this.planningSession) {
938
- try {
939
- this.planningSession.process.kill("SIGKILL");
940
- } catch {
941
- }
942
- this.planningSession = null;
1818
+ return roadmap;
1819
+ }
1820
+ /**
1821
+ * Delete a feature from the roadmap
1822
+ */
1823
+ deleteFeature(featureId) {
1824
+ const roadmap = this.getRoadmap();
1825
+ if (!roadmap) return null;
1826
+ const index = roadmap.features.findIndex((f) => f.id === featureId);
1827
+ if (index !== -1) {
1828
+ roadmap.features.splice(index, 1);
1829
+ roadmap.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1830
+ this.saveRoadmap(roadmap);
943
1831
  }
944
- this.stopAFKMode();
1832
+ return roadmap;
945
1833
  }
946
1834
  };
947
1835
 
@@ -1248,7 +2136,7 @@ function getTemplateById(id) {
1248
2136
  }
1249
2137
 
1250
2138
  // src/server/services/ai.ts
1251
- import { spawn as spawn2 } from "child_process";
2139
+ import { spawn as spawn3 } from "child_process";
1252
2140
  async function generateTaskFromPrompt(projectPath, userPrompt) {
1253
2141
  const config = getConfig(projectPath);
1254
2142
  const systemPrompt = `You are a task generator for a Kanban board used in software development.
@@ -1287,7 +2175,7 @@ User request: ${userPrompt}`;
1287
2175
  }
1288
2176
  let output = "";
1289
2177
  let errorOutput = "";
1290
- const proc = spawn2(config.agent.command, args, {
2178
+ const proc = spawn3(config.agent.command, args, {
1291
2179
  cwd: projectPath,
1292
2180
  shell: true,
1293
2181
  env: { ...process.env }
@@ -1359,6 +2247,7 @@ async function createServer(projectPath, port) {
1359
2247
  });
1360
2248
  app.use(express.json());
1361
2249
  const executor = new TaskExecutor(projectPath);
2250
+ const roadmapService = new RoadmapService(projectPath);
1362
2251
  executor.on("task:started", (data) => io.emit("task:started", data));
1363
2252
  executor.on("task:output", (data) => io.emit("task:output", data));
1364
2253
  executor.on("task:completed", (data) => io.emit("task:completed", data));
@@ -1370,6 +2259,10 @@ async function createServer(projectPath, port) {
1370
2259
  executor.on("planning:completed", (data) => io.emit("planning:completed", data));
1371
2260
  executor.on("planning:failed", (data) => io.emit("planning:failed", data));
1372
2261
  executor.on("planning:cancelled", (data) => io.emit("planning:cancelled", data));
2262
+ roadmapService.on("roadmap:started", (data) => io.emit("roadmap:started", data));
2263
+ roadmapService.on("roadmap:progress", (data) => io.emit("roadmap:progress", data));
2264
+ roadmapService.on("roadmap:completed", (data) => io.emit("roadmap:completed", data));
2265
+ roadmapService.on("roadmap:failed", (data) => io.emit("roadmap:failed", data));
1373
2266
  app.get("/api/tasks", (_req, res) => {
1374
2267
  try {
1375
2268
  const tasks = getAllTasks(projectPath);
@@ -1637,8 +2530,98 @@ async function createServer(projectPath, port) {
1637
2530
  res.status(500).json({ error: String(error) });
1638
2531
  }
1639
2532
  });
1640
- const clientPath = join5(__dirname2, "..", "client");
1641
- if (existsSync3(clientPath)) {
2533
+ app.get("/api/roadmap", (_req, res) => {
2534
+ try {
2535
+ const roadmap = roadmapService.getRoadmap();
2536
+ res.json({ roadmap });
2537
+ } catch (error) {
2538
+ res.status(500).json({ error: String(error) });
2539
+ }
2540
+ });
2541
+ app.post("/api/roadmap/generate", async (req, res) => {
2542
+ try {
2543
+ const request = req.body;
2544
+ if (roadmapService.isRunning()) {
2545
+ res.status(400).json({ error: "Roadmap generation already in progress" });
2546
+ return;
2547
+ }
2548
+ roadmapService.generate(request).catch(console.error);
2549
+ res.json({ success: true, message: "Roadmap generation started" });
2550
+ } catch (error) {
2551
+ res.status(500).json({ error: String(error) });
2552
+ }
2553
+ });
2554
+ app.post("/api/roadmap/cancel", (_req, res) => {
2555
+ try {
2556
+ if (!roadmapService.isRunning()) {
2557
+ res.status(400).json({ error: "No roadmap generation in progress" });
2558
+ return;
2559
+ }
2560
+ roadmapService.cancel();
2561
+ res.json({ success: true });
2562
+ } catch (error) {
2563
+ res.status(500).json({ error: String(error) });
2564
+ }
2565
+ });
2566
+ app.post("/api/roadmap/features/:id/add-to-kanban", (req, res) => {
2567
+ try {
2568
+ const roadmap = roadmapService.getRoadmap();
2569
+ if (!roadmap) {
2570
+ res.status(404).json({ error: "No roadmap found" });
2571
+ return;
2572
+ }
2573
+ const feature = roadmap.features.find((f) => f.id === req.params.id);
2574
+ if (!feature) {
2575
+ res.status(404).json({ error: "Feature not found" });
2576
+ return;
2577
+ }
2578
+ if (feature.addedToKanban) {
2579
+ res.status(400).json({ error: "Feature already added to kanban" });
2580
+ return;
2581
+ }
2582
+ const priorityMap = {
2583
+ must: "critical",
2584
+ should: "high",
2585
+ could: "medium",
2586
+ wont: "low"
2587
+ };
2588
+ const task = createTask(projectPath, {
2589
+ title: feature.title,
2590
+ description: feature.description,
2591
+ category: feature.category,
2592
+ priority: priorityMap[feature.priority] || "medium",
2593
+ steps: feature.steps || [],
2594
+ status: "draft"
2595
+ });
2596
+ roadmapService.markFeatureAdded(req.params.id);
2597
+ io.emit("task:created", task);
2598
+ res.json({ task });
2599
+ } catch (error) {
2600
+ res.status(500).json({ error: String(error) });
2601
+ }
2602
+ });
2603
+ app.delete("/api/roadmap/features/:id", (req, res) => {
2604
+ try {
2605
+ const roadmap = roadmapService.deleteFeature(req.params.id);
2606
+ if (!roadmap) {
2607
+ res.status(404).json({ error: "Roadmap or feature not found" });
2608
+ return;
2609
+ }
2610
+ io.emit("roadmap:updated", roadmap);
2611
+ res.json({ success: true });
2612
+ } catch (error) {
2613
+ res.status(500).json({ error: String(error) });
2614
+ }
2615
+ });
2616
+ app.get("/api/roadmap/status", (_req, res) => {
2617
+ try {
2618
+ res.json({ generating: roadmapService.isRunning() });
2619
+ } catch (error) {
2620
+ res.status(500).json({ error: String(error) });
2621
+ }
2622
+ });
2623
+ const clientPath = join6(__dirname2, "..", "client");
2624
+ if (existsSync4(clientPath)) {
1642
2625
  app.use(express.static(clientPath));
1643
2626
  }
1644
2627
  app.get("*", (_req, res) => {
@@ -2211,6 +3194,16 @@ let state = {
2211
3194
  planningOutput: [],
2212
3195
  sidePanelTab: 'logs', // 'logs' or 'details'
2213
3196
  darkMode: localStorage.getItem('darkMode') === 'true', // Add dark mode state
3197
+ // View state (board or roadmap)
3198
+ currentView: 'board', // 'board' or 'roadmap'
3199
+ // Roadmap state
3200
+ roadmap: null,
3201
+ roadmapGenerating: false,
3202
+ roadmapProgress: null,
3203
+ roadmapError: null,
3204
+ roadmapSelectedFeature: null,
3205
+ roadmapEnableCompetitors: false,
3206
+ roadmapCustomPrompt: '',
2214
3207
  };
2215
3208
 
2216
3209
  // Toast notifications
@@ -2457,6 +3450,41 @@ socket.on('planning:cancelled', () => {
2457
3450
  render();
2458
3451
  });
2459
3452
 
3453
+ // Roadmap events
3454
+ socket.on('roadmap:started', () => {
3455
+ state.roadmapGenerating = true;
3456
+ state.roadmapProgress = { phase: 'analyzing', message: 'Starting roadmap generation...' };
3457
+ state.roadmapError = null;
3458
+ render();
3459
+ });
3460
+
3461
+ socket.on('roadmap:progress', ({ phase, message }) => {
3462
+ state.roadmapProgress = { phase, message };
3463
+ render();
3464
+ });
3465
+
3466
+ socket.on('roadmap:completed', ({ roadmap }) => {
3467
+ state.roadmap = roadmap;
3468
+ state.roadmapGenerating = false;
3469
+ state.roadmapProgress = null;
3470
+ state.showModal = null;
3471
+ showToast('Roadmap generated successfully!', 'success');
3472
+ render();
3473
+ });
3474
+
3475
+ socket.on('roadmap:failed', ({ error }) => {
3476
+ state.roadmapGenerating = false;
3477
+ state.roadmapProgress = null;
3478
+ state.roadmapError = error;
3479
+ showToast('Roadmap generation failed: ' + error, 'error');
3480
+ render();
3481
+ });
3482
+
3483
+ socket.on('roadmap:updated', (roadmap) => {
3484
+ state.roadmap = roadmap;
3485
+ render();
3486
+ });
3487
+
2460
3488
  // Load templates
2461
3489
  fetch('/api/templates').then(r => r.json()).then(data => {
2462
3490
  state.templates = data.templates;
@@ -2548,6 +3576,69 @@ async function cancelPlanning() {
2548
3576
  await fetch('/api/plan/cancel', { method: 'POST' });
2549
3577
  }
2550
3578
 
3579
+ // Roadmap API functions
3580
+ async function loadRoadmap() {
3581
+ const res = await fetch('/api/roadmap');
3582
+ const data = await res.json();
3583
+ state.roadmap = data.roadmap;
3584
+ render();
3585
+ }
3586
+
3587
+ async function generateRoadmap() {
3588
+ state.roadmapGenerating = true;
3589
+ state.roadmapError = null;
3590
+ render();
3591
+ try {
3592
+ await fetch('/api/roadmap/generate', {
3593
+ method: 'POST',
3594
+ headers: { 'Content-Type': 'application/json' },
3595
+ body: JSON.stringify({
3596
+ enableCompetitorResearch: state.roadmapEnableCompetitors,
3597
+ customPrompt: state.roadmapCustomPrompt || undefined
3598
+ })
3599
+ });
3600
+ } catch (e) {
3601
+ state.roadmapGenerating = false;
3602
+ state.roadmapError = e.message;
3603
+ render();
3604
+ }
3605
+ }
3606
+
3607
+ async function cancelRoadmap() {
3608
+ await fetch('/api/roadmap/cancel', { method: 'POST' });
3609
+ state.roadmapGenerating = false;
3610
+ state.roadmapProgress = null;
3611
+ render();
3612
+ }
3613
+
3614
+ async function addFeatureToKanban(featureId) {
3615
+ try {
3616
+ const res = await fetch('/api/roadmap/features/' + featureId + '/add-to-kanban', {
3617
+ method: 'POST'
3618
+ });
3619
+ const data = await res.json();
3620
+ if (data.task) {
3621
+ showToast('Feature added to kanban!', 'success');
3622
+ // Reload roadmap to update addedToKanban status
3623
+ await loadRoadmap();
3624
+ }
3625
+ } catch (e) {
3626
+ showToast('Failed to add feature: ' + e.message, 'error');
3627
+ }
3628
+ }
3629
+
3630
+ async function deleteRoadmapFeature(featureId) {
3631
+ try {
3632
+ await fetch('/api/roadmap/features/' + featureId, { method: 'DELETE' });
3633
+ showToast('Feature removed', 'info');
3634
+ } catch (e) {
3635
+ showToast('Failed to remove feature: ' + e.message, 'error');
3636
+ }
3637
+ }
3638
+
3639
+ // Load roadmap on init
3640
+ loadRoadmap();
3641
+
2551
3642
  // Enhanced Drag and drop
2552
3643
  let draggedTask = null;
2553
3644
  let draggedElement = null;
@@ -2720,6 +3811,185 @@ function showTaskMenu(taskId) {
2720
3811
  openSidePanel(taskId);
2721
3812
  }
2722
3813
 
3814
+ // Roadmap rendering functions
3815
+ function renderRoadmap() {
3816
+ if (state.roadmapGenerating) {
3817
+ return \`
3818
+ <div class="flex-1 flex items-center justify-center">
3819
+ <div class="text-center">
3820
+ <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-accent mx-auto mb-4"></div>
3821
+ <h3 class="text-lg font-medium text-canvas-800 mb-2">Generating Roadmap...</h3>
3822
+ <p class="text-canvas-500">\${state.roadmapProgress?.message || 'Please wait...'}</p>
3823
+ <button onclick="cancelRoadmap()" class="btn btn-ghost mt-4 text-sm">Cancel</button>
3824
+ </div>
3825
+ </div>
3826
+ \`;
3827
+ }
3828
+
3829
+ if (!state.roadmap) {
3830
+ return \`
3831
+ <div class="flex-1 flex items-center justify-center">
3832
+ <div class="text-center max-w-md">
3833
+ <div class="text-6xl mb-4">\u{1F5FA}\uFE0F</div>
3834
+ <h3 class="text-xl font-semibold text-canvas-800 mb-2">No Roadmap Yet</h3>
3835
+ <p class="text-canvas-500 mb-6">Generate a strategic feature roadmap using AI to analyze your project and suggest features.</p>
3836
+ <button onclick="state.showModal = 'roadmap'; render();" class="btn btn-primary px-6 py-2">
3837
+ \u{1F680} Generate Roadmap
3838
+ </button>
3839
+ </div>
3840
+ </div>
3841
+ \`;
3842
+ }
3843
+
3844
+ const roadmap = state.roadmap;
3845
+ const phases = roadmap.phases || [];
3846
+ const features = roadmap.features || [];
3847
+
3848
+ return \`
3849
+ <div class="flex-1 overflow-y-auto p-6">
3850
+ <!-- Roadmap Header -->
3851
+ <div class="mb-6 flex items-start justify-between">
3852
+ <div>
3853
+ <h2 class="text-2xl font-semibold text-canvas-900">\${escapeHtml(roadmap.projectName)} Roadmap</h2>
3854
+ <p class="text-canvas-500 mt-1">\${escapeHtml(roadmap.projectDescription || '')}</p>
3855
+ <p class="text-sm text-canvas-400 mt-2">Target: \${escapeHtml(roadmap.targetAudience || 'Developers')}</p>
3856
+ </div>
3857
+ <div class="flex gap-2">
3858
+ <button onclick="state.showModal = 'roadmap'; render();" class="btn btn-ghost text-sm">
3859
+ \u{1F504} Regenerate
3860
+ </button>
3861
+ </div>
3862
+ </div>
3863
+
3864
+ <!-- Competitors (if available) -->
3865
+ \${roadmap.competitors && roadmap.competitors.length > 0 ? \`
3866
+ <div class="mb-6">
3867
+ <h3 class="text-sm font-medium text-canvas-700 mb-2">Competitor Insights</h3>
3868
+ <div class="flex gap-2 flex-wrap">
3869
+ \${roadmap.competitors.map(c => \`
3870
+ <span class="px-3 py-1 bg-canvas-100 rounded-full text-sm text-canvas-600">
3871
+ \${escapeHtml(c.name)}
3872
+ </span>
3873
+ \`).join('')}
3874
+ </div>
3875
+ </div>
3876
+ \` : ''}
3877
+
3878
+ <!-- Phases -->
3879
+ <div class="space-y-8">
3880
+ \${phases.map(phase => {
3881
+ const phaseFeatures = features.filter(f => f.phase === phase.name);
3882
+ return \`
3883
+ <div class="phase-section">
3884
+ <div class="flex items-center gap-3 mb-4">
3885
+ <h3 class="text-lg font-semibold text-canvas-800">\${escapeHtml(phase.name)}</h3>
3886
+ <span class="text-xs bg-canvas-100 px-2 py-0.5 rounded-full text-canvas-500">\${phaseFeatures.length} features</span>
3887
+ </div>
3888
+ <p class="text-sm text-canvas-500 mb-4">\${escapeHtml(phase.description || '')}</p>
3889
+ <div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
3890
+ \${phaseFeatures.map(f => renderRoadmapFeature(f)).join('')}
3891
+ </div>
3892
+ </div>
3893
+ \`;
3894
+ }).join('')}
3895
+ </div>
3896
+ </div>
3897
+ \`;
3898
+ }
3899
+
3900
+ function renderRoadmapFeature(feature) {
3901
+ const priorityColors = {
3902
+ must: 'bg-red-100 text-red-700',
3903
+ should: 'bg-orange-100 text-orange-700',
3904
+ could: 'bg-blue-100 text-blue-700',
3905
+ wont: 'bg-gray-100 text-gray-500'
3906
+ };
3907
+ const priorityLabels = {
3908
+ must: 'Must Have',
3909
+ should: 'Should Have',
3910
+ could: 'Could Have',
3911
+ wont: "Won't Have"
3912
+ };
3913
+ const effortIcons = {
3914
+ low: '\u26A1',
3915
+ medium: '\u23F1\uFE0F',
3916
+ high: '\u{1F3CB}\uFE0F'
3917
+ };
3918
+ const impactIcons = {
3919
+ low: '\u{1F4C9}',
3920
+ medium: '\u{1F4CA}',
3921
+ high: '\u{1F4C8}'
3922
+ };
3923
+
3924
+ return \`
3925
+ <div class="card p-4 \${feature.addedToKanban ? 'opacity-60' : ''}" onclick="state.roadmapSelectedFeature = '\${feature.id}'; render();">
3926
+ <div class="flex items-start justify-between mb-2">
3927
+ <h4 class="font-medium text-canvas-800 text-sm">\${escapeHtml(feature.title)}</h4>
3928
+ <span class="text-xs px-2 py-0.5 rounded-full \${priorityColors[feature.priority] || 'bg-gray-100'}">\${priorityLabels[feature.priority] || feature.priority}</span>
3929
+ </div>
3930
+ <p class="text-xs text-canvas-500 mb-3 line-clamp-2">\${escapeHtml(feature.description)}</p>
3931
+ <div class="flex items-center justify-between">
3932
+ <div class="flex gap-2 text-xs text-canvas-400">
3933
+ <span title="Effort">\${effortIcons[feature.effort] || '\u23F1\uFE0F'} \${feature.effort}</span>
3934
+ <span title="Impact">\${impactIcons[feature.impact] || '\u{1F4CA}'} \${feature.impact}</span>
3935
+ </div>
3936
+ \${feature.addedToKanban ? \`
3937
+ <span class="text-xs text-green-600">\u2713 Added</span>
3938
+ \` : \`
3939
+ <button onclick="event.stopPropagation(); addFeatureToKanban('\${feature.id}')" class="text-xs text-accent hover:underline">+ Add to Kanban</button>
3940
+ \`}
3941
+ </div>
3942
+ </div>
3943
+ \`;
3944
+ }
3945
+
3946
+ function renderRoadmapModal() {
3947
+ return \`
3948
+ <div class="modal-backdrop" onclick="state.showModal = null; render();">
3949
+ <div class="modal-content max-w-lg" onclick="event.stopPropagation();">
3950
+ <div class="modal-header">
3951
+ <h2 class="modal-title">Generate Roadmap</h2>
3952
+ <button onclick="state.showModal = null; render();" class="modal-close">\xD7</button>
3953
+ </div>
3954
+ <div class="modal-body">
3955
+ <p class="text-sm text-canvas-500 mb-4">
3956
+ Generate a strategic feature roadmap by analyzing your project structure and optionally researching competitors.
3957
+ </p>
3958
+
3959
+ <div class="space-y-4">
3960
+ <label class="flex items-center gap-3 cursor-pointer">
3961
+ <input type="checkbox"
3962
+ \${state.roadmapEnableCompetitors ? 'checked' : ''}
3963
+ onchange="state.roadmapEnableCompetitors = this.checked; render();"
3964
+ class="w-4 h-4 accent-accent">
3965
+ <div>
3966
+ <span class="text-sm font-medium text-canvas-700">Enable competitor research</span>
3967
+ <p class="text-xs text-canvas-400">Use web search to analyze competitors (takes longer)</p>
3968
+ </div>
3969
+ </label>
3970
+
3971
+ <div>
3972
+ <label class="text-sm font-medium text-canvas-700">Additional context (optional)</label>
3973
+ <textarea
3974
+ class="input w-full mt-1 text-sm"
3975
+ rows="3"
3976
+ placeholder="E.g., Focus on mobile features, target enterprise users..."
3977
+ oninput="state.roadmapCustomPrompt = this.value;"
3978
+ >\${escapeHtml(state.roadmapCustomPrompt)}</textarea>
3979
+ </div>
3980
+ </div>
3981
+ </div>
3982
+ <div class="modal-footer">
3983
+ <button onclick="state.showModal = null; render();" class="btn btn-ghost">Cancel</button>
3984
+ <button onclick="generateRoadmap(); state.showModal = null; render();" class="btn btn-primary">
3985
+ \u{1F680} Generate Roadmap
3986
+ </button>
3987
+ </div>
3988
+ </div>
3989
+ </div>
3990
+ \`;
3991
+ }
3992
+
2723
3993
  function renderColumn(status, title, tasks) {
2724
3994
  const columnTasks = tasks.filter(t => t.status === status);
2725
3995
  const statusLabels = {
@@ -3066,6 +4336,11 @@ function renderModal() {
3066
4336
  \`;
3067
4337
  }
3068
4338
 
4339
+ // Roadmap generation modal
4340
+ if (state.showModal === 'roadmap') {
4341
+ return renderRoadmapModal();
4342
+ }
4343
+
3069
4344
  return '';
3070
4345
  }
3071
4346
 
@@ -3385,7 +4660,7 @@ function escapeHtml(str) {
3385
4660
  // Main render
3386
4661
  function render() {
3387
4662
  const app = document.getElementById('app');
3388
- const hasSidePanel = state.sidePanel !== null;
4663
+ const hasSidePanel = state.sidePanel !== null && state.currentView === 'board';
3389
4664
 
3390
4665
  app.innerHTML = \`
3391
4666
  <div class="min-h-screen flex flex-col bg-canvas-50">
@@ -3394,51 +4669,76 @@ function render() {
3394
4669
  <div class="px-6 py-3 flex items-center justify-between">
3395
4670
  <div class="flex items-center gap-6">
3396
4671
  <h1 class="text-lg font-semibold text-canvas-900">Claude Kanban</h1>
3397
- <div class="flex items-center gap-2">
3398
- <input type="text"
3399
- placeholder="Search tasks..."
3400
- value="\${escapeHtml(state.searchQuery)}"
3401
- oninput="state.searchQuery = this.value; render();"
3402
- class="input text-sm py-1.5 w-48">
3403
- </div>
4672
+ <!-- Navigation Tabs -->
4673
+ <nav class="flex items-center gap-1 bg-canvas-100 rounded-lg p-1">
4674
+ <button onclick="state.currentView = 'board'; render();"
4675
+ class="px-3 py-1.5 text-sm rounded-md transition-colors \${state.currentView === 'board' ? 'bg-white shadow-sm text-canvas-900 font-medium' : 'text-canvas-500 hover:text-canvas-700'}">
4676
+ \u{1F4CB} Board
4677
+ </button>
4678
+ <button onclick="state.currentView = 'roadmap'; render();"
4679
+ class="px-3 py-1.5 text-sm rounded-md transition-colors \${state.currentView === 'roadmap' ? 'bg-white shadow-sm text-canvas-900 font-medium' : 'text-canvas-500 hover:text-canvas-700'}">
4680
+ \u{1F5FA}\uFE0F Roadmap
4681
+ </button>
4682
+ </nav>
4683
+ \${state.currentView === 'board' ? \`
4684
+ <div class="flex items-center gap-2">
4685
+ <input type="text"
4686
+ placeholder="Search tasks..."
4687
+ value="\${escapeHtml(state.searchQuery)}"
4688
+ oninput="state.searchQuery = this.value; render();"
4689
+ class="input text-sm py-1.5 w-48">
4690
+ </div>
4691
+ \` : ''}
3404
4692
  </div>
3405
4693
  <div class="flex items-center gap-2">
3406
- <button onclick="openPlanningModal();"
3407
- class="btn px-4 py-2 text-sm bg-blue-500 hover:bg-blue-600 text-white \${state.planning ? 'opacity-50 cursor-not-allowed' : ''}"
3408
- \${state.planning ? 'disabled' : ''}
3409
- title="AI Task Planner">
3410
- \u{1F3AF} \${state.planning ? 'Planning...' : 'Plan'}
3411
- </button>
3412
- <button onclick="state.showModal = 'new'; render();"
3413
- class="btn btn-primary px-4 py-2 text-sm">
3414
- + Add Task
3415
- </button>
3416
- <button onclick="state.showModal = 'afk'; render();"
3417
- class="btn btn-ghost px-3 py-2 text-sm \${state.afk.running ? 'text-status-success' : ''}"
3418
- title="AFK Mode">
3419
- \u{1F504} \${state.afk.running ? 'AFK On' : 'AFK'}
3420
- </button>
4694
+ \${state.currentView === 'board' ? \`
4695
+ <button onclick="openPlanningModal();"
4696
+ class="btn px-4 py-2 text-sm bg-blue-500 hover:bg-blue-600 text-white \${state.planning ? 'opacity-50 cursor-not-allowed' : ''}"
4697
+ \${state.planning ? 'disabled' : ''}
4698
+ title="AI Task Planner">
4699
+ \u{1F3AF} \${state.planning ? 'Planning...' : 'Plan'}
4700
+ </button>
4701
+ <button onclick="state.showModal = 'new'; render();"
4702
+ class="btn btn-primary px-4 py-2 text-sm">
4703
+ + Add Task
4704
+ </button>
4705
+ <button onclick="state.showModal = 'afk'; render();"
4706
+ class="btn btn-ghost px-3 py-2 text-sm \${state.afk.running ? 'text-status-success' : ''}"
4707
+ title="AFK Mode">
4708
+ \u{1F504} \${state.afk.running ? 'AFK On' : 'AFK'}
4709
+ </button>
4710
+ \` : \`
4711
+ <button onclick="state.showModal = 'roadmap'; render();"
4712
+ class="btn btn-primary px-4 py-2 text-sm \${state.roadmapGenerating ? 'opacity-50 cursor-not-allowed' : ''}"
4713
+ \${state.roadmapGenerating ? 'disabled' : ''}>
4714
+ \${state.roadmapGenerating ? '\u23F3 Generating...' : '\u{1F680} Generate Roadmap'}
4715
+ </button>
4716
+ \`}
3421
4717
  </div>
3422
4718
  </div>
3423
4719
  </header>
3424
4720
 
3425
- \${renderAFKBar()}
4721
+ \${state.currentView === 'board' ? renderAFKBar() : ''}
3426
4722
 
3427
- <!-- Main Content Area - Flex container for board + sidebar -->
4723
+ <!-- Main Content Area -->
3428
4724
  <div class="flex flex-1 overflow-hidden">
3429
- <!-- Kanban Board -->
3430
- <main class="main-content overflow-x-auto p-6">
3431
- <div class="flex gap-4">
3432
- \${renderColumn('draft', 'To Do', filterTasks(state.tasks))}
3433
- \${renderColumn('ready', 'Ready', filterTasks(state.tasks))}
3434
- \${renderColumn('in_progress', 'In Progress', filterTasks(state.tasks))}
3435
- \${renderColumn('completed', 'Done', filterTasks(state.tasks))}
3436
- \${renderColumn('failed', 'Failed', filterTasks(state.tasks))}
3437
- </div>
3438
- </main>
3439
-
3440
- <!-- Side Panel (pushes content when open) -->
3441
- \${hasSidePanel ? renderSidePanel() : ''}
4725
+ \${state.currentView === 'board' ? \`
4726
+ <!-- Kanban Board -->
4727
+ <main class="main-content overflow-x-auto p-6">
4728
+ <div class="flex gap-4">
4729
+ \${renderColumn('draft', 'To Do', filterTasks(state.tasks))}
4730
+ \${renderColumn('ready', 'Ready', filterTasks(state.tasks))}
4731
+ \${renderColumn('in_progress', 'In Progress', filterTasks(state.tasks))}
4732
+ \${renderColumn('completed', 'Done', filterTasks(state.tasks))}
4733
+ \${renderColumn('failed', 'Failed', filterTasks(state.tasks))}
4734
+ </div>
4735
+ </main>
4736
+ <!-- Side Panel (pushes content when open) -->
4737
+ \${hasSidePanel ? renderSidePanel() : ''}
4738
+ \` : \`
4739
+ <!-- Roadmap View -->
4740
+ \${renderRoadmap()}
4741
+ \`}
3442
4742
  </div>
3443
4743
 
3444
4744
  \${renderModal()}
@@ -3500,6 +4800,11 @@ window.closeSidePanel = closeSidePanel;
3500
4800
  window.showTaskMenu = showTaskMenu;
3501
4801
  window.filterTasks = filterTasks;
3502
4802
  window.scrollSidePanelLog = scrollSidePanelLog;
4803
+ window.generateRoadmap = generateRoadmap;
4804
+ window.cancelRoadmap = cancelRoadmap;
4805
+ window.addFeatureToKanban = addFeatureToKanban;
4806
+ window.deleteRoadmapFeature = deleteRoadmapFeature;
4807
+ window.loadRoadmap = loadRoadmap;
3503
4808
 
3504
4809
  // Keyboard shortcuts
3505
4810
  document.addEventListener('keydown', (e) => {