claude-kanban 0.5.0 → 0.6.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,18 +4,18 @@
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";
7
+ import { existsSync as existsSync5 } from "fs";
8
8
  import { execSync } from "child_process";
9
9
  import { createInterface } from "readline";
10
- import { join as join6 } from "path";
10
+ import { join as join7 } from "path";
11
11
 
12
12
  // src/server/index.ts
13
13
  import express from "express";
14
14
  import { createServer as createHttpServer } from "http";
15
15
  import { Server as SocketIOServer } from "socket.io";
16
- import { join as join5, dirname } from "path";
16
+ import { join as join6, dirname } from "path";
17
17
  import { fileURLToPath } from "url";
18
- import { existsSync as existsSync3 } from "fs";
18
+ import { existsSync as existsSync4 } from "fs";
19
19
 
20
20
  // src/server/services/executor.ts
21
21
  import { spawn } from "child_process";
@@ -166,8 +166,12 @@ function createInitialConfig(projectPath) {
166
166
  },
167
167
  execution: {
168
168
  maxConcurrent: 3,
169
- timeout: 30
169
+ timeout: 30,
170
170
  // 30 minutes
171
+ enableQA: false,
172
+ // QA verification disabled by default
173
+ qaMaxRetries: 2
174
+ // Max QA retry attempts
171
175
  }
172
176
  };
173
177
  }
@@ -391,7 +395,8 @@ function createTask(projectPath, request) {
391
395
  passes: false,
392
396
  createdAt: now,
393
397
  updatedAt: now,
394
- executionHistory: []
398
+ executionHistory: [],
399
+ dependsOn: request.dependsOn || []
395
400
  };
396
401
  prd.tasks.push(task);
397
402
  writePRD(projectPath, prd);
@@ -434,23 +439,40 @@ function addExecutionEntry(projectPath, taskId, entry) {
434
439
  writePRD(projectPath, prd);
435
440
  return prd.tasks[taskIndex];
436
441
  }
442
+ function areDependenciesMet(projectPath, task) {
443
+ if (!task.dependsOn || task.dependsOn.length === 0) {
444
+ return true;
445
+ }
446
+ const prd = readPRD(projectPath);
447
+ for (const depId of task.dependsOn) {
448
+ const depTask = prd.tasks.find((t) => t.id === depId);
449
+ if (!depTask || depTask.status !== "completed") {
450
+ return false;
451
+ }
452
+ }
453
+ return true;
454
+ }
437
455
  function getNextReadyTask(projectPath) {
438
456
  const readyTasks = getTasksByStatus(projectPath, "ready");
439
457
  if (readyTasks.length === 0) {
440
458
  return null;
441
459
  }
460
+ const executableTasks = readyTasks.filter((task) => areDependenciesMet(projectPath, task));
461
+ if (executableTasks.length === 0) {
462
+ return null;
463
+ }
442
464
  const priorityOrder = {
443
465
  critical: 0,
444
466
  high: 1,
445
467
  medium: 2,
446
468
  low: 3
447
469
  };
448
- readyTasks.sort((a, b) => {
470
+ executableTasks.sort((a, b) => {
449
471
  const priorityDiff = (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2);
450
472
  if (priorityDiff !== 0) return priorityDiff;
451
473
  return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
452
474
  });
453
- return readyTasks[0];
475
+ return executableTasks[0];
454
476
  }
455
477
  function getTaskCounts(projectPath) {
456
478
  const tasks = getAllTasks(projectPath);
@@ -533,6 +555,12 @@ var LOGS_DIR = "logs";
533
555
  var TaskExecutor = class extends EventEmitter {
534
556
  projectPath;
535
557
  runningTask = null;
558
+ planningSession = null;
559
+ qaSession = null;
560
+ pendingQATaskId = null;
561
+ // Task waiting for QA
562
+ qaRetryCount = /* @__PURE__ */ new Map();
563
+ // Track QA retries per task
536
564
  afkMode = false;
537
565
  afkIteration = 0;
538
566
  afkMaxIterations = 0;
@@ -658,6 +686,36 @@ ${summary}
658
686
  }
659
687
  return void 0;
660
688
  }
689
+ /**
690
+ * Check if a planning session is running
691
+ */
692
+ isPlanning() {
693
+ return this.planningSession !== null;
694
+ }
695
+ /**
696
+ * Check if executor is busy (task, planning, or QA running)
697
+ */
698
+ isBusy() {
699
+ return this.runningTask !== null || this.planningSession !== null || this.qaSession !== null;
700
+ }
701
+ /**
702
+ * Check if QA is running
703
+ */
704
+ isQARunning() {
705
+ return this.qaSession !== null;
706
+ }
707
+ /**
708
+ * Get planning session output
709
+ */
710
+ getPlanningOutput() {
711
+ return this.planningSession?.output;
712
+ }
713
+ /**
714
+ * Get planning session goal
715
+ */
716
+ getPlanningGoal() {
717
+ return this.planningSession?.goal;
718
+ }
661
719
  /**
662
720
  * Build the prompt for a task - simplified Ralph-style
663
721
  */
@@ -709,12 +767,225 @@ ${verifyCommands.length > 0 ? "6" : "5"}. Commit your changes with a descriptive
709
767
 
710
768
  Focus only on this task. When done, output: <promise>COMPLETE</promise>`;
711
769
  }
770
+ /**
771
+ * Build the prompt for a planning session
772
+ */
773
+ buildPlanningPrompt(goal) {
774
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
775
+ const prdPath = join4(kanbanDir, "prd.json");
776
+ return `You are an AI coding planner. Your job is to analyze a codebase and break down a user's goal into concrete, actionable tasks.
777
+
778
+ ## GOAL
779
+ ${goal}
780
+
781
+ ## INSTRUCTIONS
782
+
783
+ 1. Explore the codebase to understand:
784
+ - Project structure and architecture
785
+ - Existing patterns and conventions
786
+ - Related existing code that you'll build upon
787
+ - What technologies and frameworks are being used
788
+
789
+ 2. Break down the goal into 3-8 specific, actionable tasks that can each be completed in a single coding session.
790
+
791
+ 3. For each task, determine:
792
+ - Clear, action-oriented title (start with a verb: Add, Create, Implement, Fix, etc.)
793
+ - Detailed description with implementation guidance
794
+ - Category: functional, ui, bug, enhancement, testing, or refactor
795
+ - Priority: low, medium, high, or critical
796
+ - 3-7 verification steps to confirm the task is complete
797
+
798
+ 4. Read the current PRD file at ${prdPath}, then update it:
799
+ - Add your new tasks to the "tasks" array
800
+ - Each task must have this structure:
801
+ {
802
+ "id": "task_" + 8 random alphanumeric characters,
803
+ "title": "...",
804
+ "description": "...",
805
+ "category": "functional|ui|bug|enhancement|testing|refactor",
806
+ "priority": "low|medium|high|critical",
807
+ "status": "draft",
808
+ "steps": ["Step 1", "Step 2", ...],
809
+ "passes": false,
810
+ "createdAt": ISO timestamp,
811
+ "updatedAt": ISO timestamp,
812
+ "executionHistory": []
813
+ }
814
+
815
+ 5. Commit your changes with message: "Plan: ${goal}"
816
+
817
+ IMPORTANT:
818
+ - Tasks should be ordered logically (dependencies first)
819
+ - Each task should be completable independently
820
+ - Be specific about implementation details
821
+ - Consider edge cases and error handling
822
+
823
+ When done, output: <promise>PLANNING_COMPLETE</promise>`;
824
+ }
825
+ /**
826
+ * Run a planning session to break down a goal into tasks
827
+ */
828
+ async runPlanningSession(goal) {
829
+ if (this.isBusy()) {
830
+ throw new Error("Another operation is in progress. Wait for it to complete or cancel it first.");
831
+ }
832
+ const config = getConfig(this.projectPath);
833
+ const startedAt = /* @__PURE__ */ new Date();
834
+ const prompt = this.buildPlanningPrompt(goal);
835
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
836
+ const promptFile = join4(kanbanDir, "prompt-planning.txt");
837
+ writeFileSync4(promptFile, prompt);
838
+ const args = [];
839
+ if (config.agent.model) {
840
+ args.push("--model", config.agent.model);
841
+ }
842
+ args.push("--permission-mode", config.agent.permissionMode);
843
+ args.push("-p");
844
+ args.push("--verbose");
845
+ args.push("--output-format", "stream-json");
846
+ args.push(`@${promptFile}`);
847
+ const commandDisplay = `${config.agent.command} ${args.join(" ")}`;
848
+ const fullCommand = `${config.agent.command} ${args.join(" ")}`;
849
+ console.log("[executor] Planning command:", fullCommand);
850
+ console.log("[executor] CWD:", this.projectPath);
851
+ const childProcess = spawn("bash", ["-c", fullCommand], {
852
+ cwd: this.projectPath,
853
+ env: {
854
+ ...process.env,
855
+ TERM: "xterm-256color",
856
+ FORCE_COLOR: "0",
857
+ NO_COLOR: "1"
858
+ },
859
+ stdio: ["ignore", "pipe", "pipe"]
860
+ });
861
+ this.planningSession = {
862
+ goal,
863
+ process: childProcess,
864
+ startedAt,
865
+ output: []
866
+ };
867
+ const logOutput = (line) => {
868
+ this.planningSession?.output.push(line);
869
+ this.emit("planning:output", { line, lineType: "stdout" });
870
+ };
871
+ this.emit("planning:started", { goal, timestamp: startedAt.toISOString() });
872
+ logOutput(`[claude-kanban] Starting planning session
873
+ `);
874
+ logOutput(`[claude-kanban] Goal: ${goal}
875
+ `);
876
+ logOutput(`[claude-kanban] Command: ${commandDisplay}
877
+ `);
878
+ let stdoutBuffer = "";
879
+ childProcess.stdout?.on("data", (data) => {
880
+ stdoutBuffer += data.toString();
881
+ const lines = stdoutBuffer.split("\n");
882
+ stdoutBuffer = lines.pop() || "";
883
+ for (const line of lines) {
884
+ if (!line.trim()) continue;
885
+ try {
886
+ const json = JSON.parse(line);
887
+ let text = "";
888
+ if (json.type === "assistant" && json.message?.content) {
889
+ for (const block of json.message.content) {
890
+ if (block.type === "text") {
891
+ text += block.text;
892
+ } else if (block.type === "tool_use") {
893
+ text += this.formatToolUse(block.name, block.input);
894
+ }
895
+ }
896
+ } else if (json.type === "content_block_delta" && json.delta?.text) {
897
+ text = json.delta.text;
898
+ } else if (json.type === "result" && json.result) {
899
+ text = `
900
+ [Result: ${json.result}]
901
+ `;
902
+ }
903
+ if (text) {
904
+ logOutput(text);
905
+ }
906
+ } catch {
907
+ const cleanText = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
908
+ if (cleanText.trim()) {
909
+ logOutput(cleanText + "\n");
910
+ }
911
+ }
912
+ }
913
+ });
914
+ childProcess.stderr?.on("data", (data) => {
915
+ const text = data.toString();
916
+ logOutput(`[stderr] ${text}`);
917
+ });
918
+ childProcess.on("error", (error) => {
919
+ console.log("[executor] Planning spawn error:", error.message);
920
+ this.emit("planning:output", { line: `[claude-kanban] Error: ${error.message}
921
+ `, lineType: "stderr" });
922
+ try {
923
+ unlinkSync(promptFile);
924
+ } catch {
925
+ }
926
+ this.emit("planning:failed", { error: error.message });
927
+ this.planningSession = null;
928
+ });
929
+ childProcess.on("close", (code, signal) => {
930
+ console.log("[executor] Planning process closed with code:", code, "signal:", signal);
931
+ try {
932
+ unlinkSync(promptFile);
933
+ } catch {
934
+ }
935
+ logOutput(`[claude-kanban] Planning process exited with code ${code}
936
+ `);
937
+ this.handlePlanningComplete(code);
938
+ });
939
+ const timeoutMs = 15 * 60 * 1e3;
940
+ setTimeout(() => {
941
+ if (this.planningSession) {
942
+ this.cancelPlanning("Planning timeout exceeded");
943
+ }
944
+ }, timeoutMs);
945
+ }
946
+ /**
947
+ * Handle planning session completion
948
+ */
949
+ handlePlanningComplete(exitCode) {
950
+ if (!this.planningSession) return;
951
+ const output = this.planningSession.output.join("");
952
+ const isComplete = output.includes("<promise>PLANNING_COMPLETE</promise>");
953
+ if (isComplete || exitCode === 0) {
954
+ this.emit("planning:completed", { success: true });
955
+ } else {
956
+ const error = `Planning process exited with code ${exitCode}`;
957
+ this.emit("planning:failed", { error });
958
+ }
959
+ this.planningSession = null;
960
+ }
961
+ /**
962
+ * Cancel the planning session
963
+ */
964
+ cancelPlanning(reason = "Cancelled by user") {
965
+ if (!this.planningSession) return false;
966
+ const { process: childProcess } = this.planningSession;
967
+ try {
968
+ childProcess.kill("SIGTERM");
969
+ setTimeout(() => {
970
+ try {
971
+ if (!childProcess.killed) {
972
+ childProcess.kill("SIGKILL");
973
+ }
974
+ } catch {
975
+ }
976
+ }, 2e3);
977
+ } catch {
978
+ }
979
+ this.emit("planning:cancelled", { reason });
980
+ this.planningSession = null;
981
+ return true;
982
+ }
712
983
  /**
713
984
  * Run a task
714
985
  */
715
986
  async runTask(taskId) {
716
- if (this.isRunning()) {
717
- throw new Error("A task is already running. Wait for it to complete or cancel it first.");
987
+ if (this.isBusy()) {
988
+ throw new Error("Another operation is in progress. Wait for it to complete or cancel it first.");
718
989
  }
719
990
  const config = getConfig(this.projectPath);
720
991
  const task = getTaskById(this.projectPath, taskId);
@@ -854,27 +1125,52 @@ Focus only on this task. When done, output: <promise>COMPLETE</promise>`;
854
1125
  const endedAt = /* @__PURE__ */ new Date();
855
1126
  const duration = endedAt.getTime() - startedAt.getTime();
856
1127
  const output = this.runningTask.output.join("");
1128
+ const config = getConfig(this.projectPath);
857
1129
  const isComplete = output.includes("<promise>COMPLETE</promise>");
858
1130
  const task = getTaskById(this.projectPath, taskId);
859
1131
  if (isComplete || exitCode === 0) {
860
- updateTask(this.projectPath, taskId, {
861
- status: "completed",
862
- passes: true
863
- });
864
- addExecutionEntry(this.projectPath, taskId, {
865
- startedAt: startedAt.toISOString(),
866
- endedAt: endedAt.toISOString(),
867
- status: "completed",
868
- duration
869
- });
870
- logTaskExecution(this.projectPath, {
871
- taskId,
872
- taskTitle: task?.title || "Unknown",
873
- status: "completed",
874
- duration
875
- });
876
- this.emit("task:completed", { taskId, duration });
877
- this.afkTasksCompleted++;
1132
+ if (config.execution.enableQA) {
1133
+ updateTask(this.projectPath, taskId, {
1134
+ status: "in_progress",
1135
+ // Keep in progress until QA passes
1136
+ passes: false
1137
+ });
1138
+ addExecutionEntry(this.projectPath, taskId, {
1139
+ startedAt: startedAt.toISOString(),
1140
+ endedAt: endedAt.toISOString(),
1141
+ status: "completed",
1142
+ duration
1143
+ });
1144
+ this.runningTask = null;
1145
+ this.pendingQATaskId = taskId;
1146
+ this.runQAVerification(taskId).catch((error) => {
1147
+ console.error("QA verification error:", error);
1148
+ this.handleQAComplete(taskId, false, ["QA process failed to start"]);
1149
+ });
1150
+ } else {
1151
+ updateTask(this.projectPath, taskId, {
1152
+ status: "completed",
1153
+ passes: true
1154
+ });
1155
+ addExecutionEntry(this.projectPath, taskId, {
1156
+ startedAt: startedAt.toISOString(),
1157
+ endedAt: endedAt.toISOString(),
1158
+ status: "completed",
1159
+ duration
1160
+ });
1161
+ logTaskExecution(this.projectPath, {
1162
+ taskId,
1163
+ taskTitle: task?.title || "Unknown",
1164
+ status: "completed",
1165
+ duration
1166
+ });
1167
+ this.emit("task:completed", { taskId, duration });
1168
+ this.afkTasksCompleted++;
1169
+ this.runningTask = null;
1170
+ if (this.afkMode) {
1171
+ this.continueAFKMode();
1172
+ }
1173
+ }
878
1174
  } else {
879
1175
  updateTask(this.projectPath, taskId, {
880
1176
  status: "failed",
@@ -896,63 +1192,448 @@ Focus only on this task. When done, output: <promise>COMPLETE</promise>`;
896
1192
  error
897
1193
  });
898
1194
  this.emit("task:failed", { taskId, error });
899
- }
900
- this.runningTask = null;
901
- if (this.afkMode) {
902
- this.continueAFKMode();
1195
+ this.runningTask = null;
1196
+ if (this.afkMode) {
1197
+ this.continueAFKMode();
1198
+ }
903
1199
  }
904
1200
  }
905
1201
  /**
906
- * Cancel the running task
1202
+ * Build the QA verification prompt
907
1203
  */
908
- cancelTask(reason = "Cancelled by user") {
909
- if (!this.runningTask) return false;
910
- const { taskId, process: childProcess, startedAt } = this.runningTask;
911
- const endedAt = /* @__PURE__ */ new Date();
912
- const duration = endedAt.getTime() - startedAt.getTime();
913
- const task = getTaskById(this.projectPath, taskId);
914
- try {
915
- childProcess.kill("SIGTERM");
916
- setTimeout(() => {
917
- try {
918
- if (!childProcess.killed) {
919
- childProcess.kill("SIGKILL");
920
- }
921
- } catch {
922
- }
923
- }, 2e3);
924
- } catch {
1204
+ buildQAPrompt(task, config) {
1205
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
1206
+ const prdPath = join4(kanbanDir, "prd.json");
1207
+ const stepsText = task.steps.length > 0 ? `
1208
+ Verification steps:
1209
+ ${task.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}` : "";
1210
+ const verifyCommands = [];
1211
+ if (config.project.typecheckCommand) {
1212
+ verifyCommands.push(config.project.typecheckCommand);
925
1213
  }
926
- updateTask(this.projectPath, taskId, { status: "ready" });
927
- addExecutionEntry(this.projectPath, taskId, {
928
- startedAt: startedAt.toISOString(),
929
- endedAt: endedAt.toISOString(),
930
- status: "cancelled",
931
- duration,
932
- error: reason
933
- });
934
- logTaskExecution(this.projectPath, {
935
- taskId,
936
- taskTitle: task?.title || "Unknown",
937
- status: "cancelled",
938
- duration,
939
- error: reason
940
- });
941
- this.emit("task:cancelled", { taskId });
942
- this.runningTask = null;
943
- return true;
1214
+ if (config.project.testCommand) {
1215
+ verifyCommands.push(config.project.testCommand);
1216
+ }
1217
+ return `You are a QA reviewer. Verify that the following task has been correctly implemented.
1218
+
1219
+ ## TASK TO VERIFY
1220
+ Title: ${task.title}
1221
+ Category: ${task.category}
1222
+ Priority: ${task.priority}
1223
+
1224
+ ${task.description}
1225
+ ${stepsText}
1226
+
1227
+ ## YOUR JOB
1228
+
1229
+ 1. Review the recent git commits and changes made for this task.
1230
+
1231
+ 2. Check that all verification steps (if any) have been satisfied.
1232
+
1233
+ 3. Run the following quality checks:
1234
+ ${verifyCommands.length > 0 ? verifyCommands.map((cmd) => ` - ${cmd}`).join("\n") : " (No verification commands configured)"}
1235
+
1236
+ 4. Check that the implementation meets the task requirements.
1237
+
1238
+ 5. If everything passes, output: <qa>PASSED</qa>
1239
+
1240
+ 6. If there are issues, output: <qa>FAILED</qa>
1241
+ Then list the issues that need to be fixed:
1242
+ <issues>
1243
+ - Issue 1
1244
+ - Issue 2
1245
+ </issues>
1246
+
1247
+ Be thorough but fair. Only fail the QA if there are real issues that affect functionality or quality.`;
944
1248
  }
945
1249
  /**
946
- * Start AFK mode - run tasks sequentially until done
1250
+ * Run QA verification for a task
947
1251
  */
948
- startAFKMode(maxIterations) {
949
- if (this.afkMode) {
950
- throw new Error("AFK mode already running");
951
- }
952
- if (this.isRunning()) {
953
- throw new Error("Cannot start AFK mode while a task is running");
1252
+ async runQAVerification(taskId) {
1253
+ const config = getConfig(this.projectPath);
1254
+ const task = getTaskById(this.projectPath, taskId);
1255
+ if (!task) {
1256
+ throw new Error(`Task not found: ${taskId}`);
954
1257
  }
955
- this.afkMode = true;
1258
+ const currentRetries = this.qaRetryCount.get(taskId) || 0;
1259
+ const attempt = currentRetries + 1;
1260
+ const startedAt = /* @__PURE__ */ new Date();
1261
+ const prompt = this.buildQAPrompt(task, config);
1262
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
1263
+ const promptFile = join4(kanbanDir, `qa-${taskId}.txt`);
1264
+ writeFileSync4(promptFile, prompt);
1265
+ const args = [];
1266
+ if (config.agent.model) {
1267
+ args.push("--model", config.agent.model);
1268
+ }
1269
+ args.push("--permission-mode", config.agent.permissionMode);
1270
+ args.push("-p");
1271
+ args.push("--verbose");
1272
+ args.push("--output-format", "stream-json");
1273
+ args.push(`@${promptFile}`);
1274
+ const fullCommand = `${config.agent.command} ${args.join(" ")}`;
1275
+ console.log("[executor] QA command:", fullCommand);
1276
+ const childProcess = spawn("bash", ["-c", fullCommand], {
1277
+ cwd: this.projectPath,
1278
+ env: {
1279
+ ...process.env,
1280
+ TERM: "xterm-256color",
1281
+ FORCE_COLOR: "0",
1282
+ NO_COLOR: "1"
1283
+ },
1284
+ stdio: ["ignore", "pipe", "pipe"]
1285
+ });
1286
+ this.qaSession = {
1287
+ taskId,
1288
+ process: childProcess,
1289
+ startedAt,
1290
+ output: [],
1291
+ attempt
1292
+ };
1293
+ this.emit("qa:started", { taskId, attempt });
1294
+ const logOutput = (line) => {
1295
+ this.qaSession?.output.push(line);
1296
+ this.emit("qa:output", { taskId, line });
1297
+ };
1298
+ logOutput(`[claude-kanban] Starting QA verification (attempt ${attempt})
1299
+ `);
1300
+ let stdoutBuffer = "";
1301
+ childProcess.stdout?.on("data", (data) => {
1302
+ stdoutBuffer += data.toString();
1303
+ const lines = stdoutBuffer.split("\n");
1304
+ stdoutBuffer = lines.pop() || "";
1305
+ for (const line of lines) {
1306
+ if (!line.trim()) continue;
1307
+ try {
1308
+ const json = JSON.parse(line);
1309
+ let text = "";
1310
+ if (json.type === "assistant" && json.message?.content) {
1311
+ for (const block of json.message.content) {
1312
+ if (block.type === "text") {
1313
+ text += block.text;
1314
+ } else if (block.type === "tool_use") {
1315
+ text += this.formatToolUse(block.name, block.input);
1316
+ }
1317
+ }
1318
+ } else if (json.type === "content_block_delta" && json.delta?.text) {
1319
+ text = json.delta.text;
1320
+ }
1321
+ if (text) {
1322
+ logOutput(text);
1323
+ }
1324
+ } catch {
1325
+ const cleanText = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
1326
+ if (cleanText.trim()) {
1327
+ logOutput(cleanText + "\n");
1328
+ }
1329
+ }
1330
+ }
1331
+ });
1332
+ childProcess.stderr?.on("data", (data) => {
1333
+ const text = data.toString();
1334
+ logOutput(`[stderr] ${text}`);
1335
+ });
1336
+ childProcess.on("error", (error) => {
1337
+ console.log("[executor] QA spawn error:", error.message);
1338
+ try {
1339
+ unlinkSync(promptFile);
1340
+ } catch {
1341
+ }
1342
+ this.handleQAComplete(taskId, false, [`QA process error: ${error.message}`]);
1343
+ });
1344
+ childProcess.on("close", (code) => {
1345
+ console.log("[executor] QA process closed with code:", code);
1346
+ try {
1347
+ unlinkSync(promptFile);
1348
+ } catch {
1349
+ }
1350
+ this.parseQAResult(taskId);
1351
+ });
1352
+ const timeoutMs = 5 * 60 * 1e3;
1353
+ setTimeout(() => {
1354
+ if (this.qaSession?.taskId === taskId) {
1355
+ this.cancelQA("QA timeout exceeded");
1356
+ }
1357
+ }, timeoutMs);
1358
+ }
1359
+ /**
1360
+ * Parse QA result from output
1361
+ */
1362
+ parseQAResult(taskId) {
1363
+ if (!this.qaSession || this.qaSession.taskId !== taskId) return;
1364
+ const output = this.qaSession.output.join("");
1365
+ const passedMatch = output.includes("<qa>PASSED</qa>");
1366
+ const failedMatch = output.includes("<qa>FAILED</qa>");
1367
+ if (passedMatch) {
1368
+ this.handleQAComplete(taskId, true, []);
1369
+ } else if (failedMatch) {
1370
+ const issuesMatch = output.match(/<issues>([\s\S]*?)<\/issues>/);
1371
+ const issues = [];
1372
+ if (issuesMatch) {
1373
+ const issueLines = issuesMatch[1].split("\n");
1374
+ for (const line of issueLines) {
1375
+ const trimmed = line.trim();
1376
+ if (trimmed.startsWith("-")) {
1377
+ issues.push(trimmed.substring(1).trim());
1378
+ } else if (trimmed) {
1379
+ issues.push(trimmed);
1380
+ }
1381
+ }
1382
+ }
1383
+ this.handleQAComplete(taskId, false, issues.length > 0 ? issues : ["QA verification failed"]);
1384
+ } else {
1385
+ this.handleQAComplete(taskId, false, ["QA did not provide a clear PASSED/FAILED result"]);
1386
+ }
1387
+ }
1388
+ /**
1389
+ * Handle QA completion
1390
+ */
1391
+ handleQAComplete(taskId, passed, issues) {
1392
+ const config = getConfig(this.projectPath);
1393
+ const task = getTaskById(this.projectPath, taskId);
1394
+ const currentRetries = this.qaRetryCount.get(taskId) || 0;
1395
+ this.qaSession = null;
1396
+ this.pendingQATaskId = null;
1397
+ if (passed) {
1398
+ updateTask(this.projectPath, taskId, {
1399
+ status: "completed",
1400
+ passes: true
1401
+ });
1402
+ logTaskExecution(this.projectPath, {
1403
+ taskId,
1404
+ taskTitle: task?.title || "Unknown",
1405
+ status: "completed",
1406
+ duration: 0
1407
+ });
1408
+ this.emit("qa:passed", { taskId });
1409
+ this.emit("task:completed", { taskId, duration: 0 });
1410
+ this.afkTasksCompleted++;
1411
+ this.qaRetryCount.delete(taskId);
1412
+ } else {
1413
+ const willRetry = currentRetries < config.execution.qaMaxRetries;
1414
+ this.emit("qa:failed", { taskId, issues, willRetry });
1415
+ if (willRetry) {
1416
+ this.qaRetryCount.set(taskId, currentRetries + 1);
1417
+ console.log(`[executor] QA failed, retrying (${currentRetries + 1}/${config.execution.qaMaxRetries})`);
1418
+ this.runTaskWithQAFix(taskId, issues).catch((error) => {
1419
+ console.error("QA fix error:", error);
1420
+ this.handleQAComplete(taskId, false, ["Failed to run QA fix"]);
1421
+ });
1422
+ } else {
1423
+ updateTask(this.projectPath, taskId, {
1424
+ status: "failed",
1425
+ passes: false
1426
+ });
1427
+ logTaskExecution(this.projectPath, {
1428
+ taskId,
1429
+ taskTitle: task?.title || "Unknown",
1430
+ status: "failed",
1431
+ duration: 0,
1432
+ error: `QA failed after ${config.execution.qaMaxRetries} retries: ${issues.join(", ")}`
1433
+ });
1434
+ this.emit("task:failed", { taskId, error: `QA failed: ${issues.join(", ")}` });
1435
+ this.qaRetryCount.delete(taskId);
1436
+ }
1437
+ }
1438
+ if (this.afkMode && !this.isBusy()) {
1439
+ this.continueAFKMode();
1440
+ }
1441
+ }
1442
+ /**
1443
+ * Run task again with QA fix instructions
1444
+ */
1445
+ async runTaskWithQAFix(taskId, issues) {
1446
+ const config = getConfig(this.projectPath);
1447
+ const task = getTaskById(this.projectPath, taskId);
1448
+ if (!task) {
1449
+ throw new Error(`Task not found: ${taskId}`);
1450
+ }
1451
+ const startedAt = /* @__PURE__ */ new Date();
1452
+ const issuesText = issues.map((i, idx) => `${idx + 1}. ${i}`).join("\n");
1453
+ const prompt = `You are fixing issues found during QA verification for a task.
1454
+
1455
+ ## ORIGINAL TASK
1456
+ Title: ${task.title}
1457
+ ${task.description}
1458
+
1459
+ ## QA ISSUES TO FIX
1460
+ ${issuesText}
1461
+
1462
+ ## INSTRUCTIONS
1463
+ 1. Review the issues reported by QA.
1464
+ 2. Fix each issue.
1465
+ 3. Run verification commands to ensure fixes work.
1466
+ 4. When done, output: <promise>COMPLETE</promise>
1467
+
1468
+ Focus only on fixing the reported issues, don't make unrelated changes.`;
1469
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
1470
+ const promptFile = join4(kanbanDir, `qafix-${taskId}.txt`);
1471
+ writeFileSync4(promptFile, prompt);
1472
+ const args = [];
1473
+ if (config.agent.model) {
1474
+ args.push("--model", config.agent.model);
1475
+ }
1476
+ args.push("--permission-mode", config.agent.permissionMode);
1477
+ args.push("-p");
1478
+ args.push("--verbose");
1479
+ args.push("--output-format", "stream-json");
1480
+ args.push(`@${promptFile}`);
1481
+ const fullCommand = `${config.agent.command} ${args.join(" ")}`;
1482
+ const childProcess = spawn("bash", ["-c", fullCommand], {
1483
+ cwd: this.projectPath,
1484
+ env: {
1485
+ ...process.env,
1486
+ TERM: "xterm-256color",
1487
+ FORCE_COLOR: "0",
1488
+ NO_COLOR: "1"
1489
+ },
1490
+ stdio: ["ignore", "pipe", "pipe"]
1491
+ });
1492
+ this.runningTask = {
1493
+ taskId,
1494
+ process: childProcess,
1495
+ startedAt,
1496
+ output: []
1497
+ };
1498
+ this.emit("task:started", { taskId, timestamp: startedAt.toISOString() });
1499
+ let stdoutBuffer = "";
1500
+ childProcess.stdout?.on("data", (data) => {
1501
+ stdoutBuffer += data.toString();
1502
+ const lines = stdoutBuffer.split("\n");
1503
+ stdoutBuffer = lines.pop() || "";
1504
+ for (const line of lines) {
1505
+ if (!line.trim()) continue;
1506
+ try {
1507
+ const json = JSON.parse(line);
1508
+ let text = "";
1509
+ if (json.type === "assistant" && json.message?.content) {
1510
+ for (const block of json.message.content) {
1511
+ if (block.type === "text") {
1512
+ text += block.text;
1513
+ } else if (block.type === "tool_use") {
1514
+ text += this.formatToolUse(block.name, block.input);
1515
+ }
1516
+ }
1517
+ } else if (json.type === "content_block_delta" && json.delta?.text) {
1518
+ text = json.delta.text;
1519
+ }
1520
+ if (text) {
1521
+ this.runningTask?.output.push(text);
1522
+ this.emit("task:output", { taskId, line: text, lineType: "stdout" });
1523
+ }
1524
+ } catch {
1525
+ const cleanText = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
1526
+ if (cleanText.trim()) {
1527
+ this.runningTask?.output.push(cleanText + "\n");
1528
+ this.emit("task:output", { taskId, line: cleanText + "\n", lineType: "stdout" });
1529
+ }
1530
+ }
1531
+ }
1532
+ });
1533
+ childProcess.stderr?.on("data", (data) => {
1534
+ const text = data.toString();
1535
+ this.runningTask?.output.push(`[stderr] ${text}`);
1536
+ this.emit("task:output", { taskId, line: `[stderr] ${text}`, lineType: "stderr" });
1537
+ });
1538
+ childProcess.on("error", (error) => {
1539
+ try {
1540
+ unlinkSync(promptFile);
1541
+ } catch {
1542
+ }
1543
+ this.handleQAComplete(taskId, false, [`Fix process error: ${error.message}`]);
1544
+ });
1545
+ childProcess.on("close", (code) => {
1546
+ try {
1547
+ unlinkSync(promptFile);
1548
+ } catch {
1549
+ }
1550
+ const output = this.runningTask?.output.join("") || "";
1551
+ const isComplete = output.includes("<promise>COMPLETE</promise>");
1552
+ this.runningTask = null;
1553
+ if (isComplete || code === 0) {
1554
+ this.runQAVerification(taskId).catch((error) => {
1555
+ console.error("QA verification error after fix:", error);
1556
+ this.handleQAComplete(taskId, false, ["QA verification failed after fix"]);
1557
+ });
1558
+ } else {
1559
+ this.handleQAComplete(taskId, false, [`Fix process exited with code ${code}`]);
1560
+ }
1561
+ });
1562
+ }
1563
+ /**
1564
+ * Cancel QA verification
1565
+ */
1566
+ cancelQA(reason = "Cancelled") {
1567
+ if (!this.qaSession) return false;
1568
+ const { taskId, process: childProcess } = this.qaSession;
1569
+ try {
1570
+ childProcess.kill("SIGTERM");
1571
+ setTimeout(() => {
1572
+ try {
1573
+ if (!childProcess.killed) {
1574
+ childProcess.kill("SIGKILL");
1575
+ }
1576
+ } catch {
1577
+ }
1578
+ }, 2e3);
1579
+ } catch {
1580
+ }
1581
+ this.emit("qa:failed", { taskId, issues: [reason], willRetry: false });
1582
+ this.qaSession = null;
1583
+ this.pendingQATaskId = null;
1584
+ return true;
1585
+ }
1586
+ /**
1587
+ * Cancel the running task
1588
+ */
1589
+ cancelTask(reason = "Cancelled by user") {
1590
+ if (!this.runningTask) return false;
1591
+ const { taskId, process: childProcess, startedAt } = this.runningTask;
1592
+ const endedAt = /* @__PURE__ */ new Date();
1593
+ const duration = endedAt.getTime() - startedAt.getTime();
1594
+ const task = getTaskById(this.projectPath, taskId);
1595
+ try {
1596
+ childProcess.kill("SIGTERM");
1597
+ setTimeout(() => {
1598
+ try {
1599
+ if (!childProcess.killed) {
1600
+ childProcess.kill("SIGKILL");
1601
+ }
1602
+ } catch {
1603
+ }
1604
+ }, 2e3);
1605
+ } catch {
1606
+ }
1607
+ updateTask(this.projectPath, taskId, { status: "ready" });
1608
+ addExecutionEntry(this.projectPath, taskId, {
1609
+ startedAt: startedAt.toISOString(),
1610
+ endedAt: endedAt.toISOString(),
1611
+ status: "cancelled",
1612
+ duration,
1613
+ error: reason
1614
+ });
1615
+ logTaskExecution(this.projectPath, {
1616
+ taskId,
1617
+ taskTitle: task?.title || "Unknown",
1618
+ status: "cancelled",
1619
+ duration,
1620
+ error: reason
1621
+ });
1622
+ this.emit("task:cancelled", { taskId });
1623
+ this.runningTask = null;
1624
+ return true;
1625
+ }
1626
+ /**
1627
+ * Start AFK mode - run tasks sequentially until done
1628
+ */
1629
+ startAFKMode(maxIterations) {
1630
+ if (this.afkMode) {
1631
+ throw new Error("AFK mode already running");
1632
+ }
1633
+ if (this.isBusy()) {
1634
+ throw new Error("Cannot start AFK mode while another operation is in progress");
1635
+ }
1636
+ this.afkMode = true;
956
1637
  this.afkIteration = 0;
957
1638
  this.afkMaxIterations = maxIterations;
958
1639
  this.afkTasksCompleted = 0;
@@ -960,67 +1641,523 @@ Focus only on this task. When done, output: <promise>COMPLETE</promise>`;
960
1641
  this.continueAFKMode();
961
1642
  }
962
1643
  /**
963
- * Continue AFK mode - pick next task
1644
+ * Continue AFK mode - pick next task
1645
+ */
1646
+ continueAFKMode() {
1647
+ if (!this.afkMode) return;
1648
+ if (this.afkIteration >= this.afkMaxIterations) {
1649
+ this.stopAFKMode();
1650
+ return;
1651
+ }
1652
+ if (this.isBusy()) return;
1653
+ const nextTask = getNextReadyTask(this.projectPath);
1654
+ if (!nextTask) {
1655
+ this.stopAFKMode();
1656
+ return;
1657
+ }
1658
+ this.afkIteration++;
1659
+ this.runTask(nextTask.id).catch((error) => {
1660
+ console.error("AFK task error:", error);
1661
+ });
1662
+ this.emitAFKStatus();
1663
+ }
1664
+ /**
1665
+ * Stop AFK mode
1666
+ */
1667
+ stopAFKMode() {
1668
+ this.afkMode = false;
1669
+ this.emitAFKStatus();
1670
+ }
1671
+ /**
1672
+ * Emit AFK status
1673
+ */
1674
+ emitAFKStatus() {
1675
+ this.emit("afk:status", {
1676
+ running: this.afkMode,
1677
+ currentIteration: this.afkIteration,
1678
+ maxIterations: this.afkMaxIterations,
1679
+ tasksCompleted: this.afkTasksCompleted
1680
+ });
1681
+ }
1682
+ /**
1683
+ * Get AFK status
1684
+ */
1685
+ getAFKStatus() {
1686
+ return {
1687
+ running: this.afkMode,
1688
+ currentIteration: this.afkIteration,
1689
+ maxIterations: this.afkMaxIterations,
1690
+ tasksCompleted: this.afkTasksCompleted
1691
+ };
1692
+ }
1693
+ /**
1694
+ * Cancel running task/planning/QA and stop AFK mode
1695
+ */
1696
+ cancelAll() {
1697
+ if (this.runningTask) {
1698
+ try {
1699
+ this.runningTask.process.kill("SIGKILL");
1700
+ } catch {
1701
+ }
1702
+ this.runningTask = null;
1703
+ }
1704
+ if (this.planningSession) {
1705
+ try {
1706
+ this.planningSession.process.kill("SIGKILL");
1707
+ } catch {
1708
+ }
1709
+ this.planningSession = null;
1710
+ }
1711
+ if (this.qaSession) {
1712
+ try {
1713
+ this.qaSession.process.kill("SIGKILL");
1714
+ } catch {
1715
+ }
1716
+ this.qaSession = null;
1717
+ }
1718
+ this.pendingQATaskId = null;
1719
+ this.qaRetryCount.clear();
1720
+ this.stopAFKMode();
1721
+ }
1722
+ };
1723
+
1724
+ // src/server/services/roadmap.ts
1725
+ import { spawn as spawn2 } from "child_process";
1726
+ import { EventEmitter as EventEmitter2 } from "events";
1727
+ import { existsSync as existsSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync3 } from "fs";
1728
+ import { join as join5, basename as basename2 } from "path";
1729
+ import { nanoid as nanoid2 } from "nanoid";
1730
+ var ROADMAP_DIR = ".claude-kanban";
1731
+ var ROADMAP_FILE = "roadmap.json";
1732
+ var RoadmapService = class extends EventEmitter2 {
1733
+ projectPath;
1734
+ session = null;
1735
+ config = null;
1736
+ constructor(projectPath) {
1737
+ super();
1738
+ this.projectPath = projectPath;
1739
+ this.loadConfig();
1740
+ }
1741
+ loadConfig() {
1742
+ const configPath = join5(this.projectPath, ROADMAP_DIR, "config.json");
1743
+ if (existsSync3(configPath)) {
1744
+ this.config = JSON.parse(readFileSync5(configPath, "utf-8"));
1745
+ }
1746
+ }
1747
+ /**
1748
+ * Check if roadmap generation is in progress
1749
+ */
1750
+ isRunning() {
1751
+ return this.session !== null;
1752
+ }
1753
+ /**
1754
+ * Get existing roadmap if it exists
1755
+ */
1756
+ getRoadmap() {
1757
+ const roadmapPath = join5(this.projectPath, ROADMAP_DIR, ROADMAP_FILE);
1758
+ if (!existsSync3(roadmapPath)) {
1759
+ return null;
1760
+ }
1761
+ try {
1762
+ return JSON.parse(readFileSync5(roadmapPath, "utf-8"));
1763
+ } catch {
1764
+ return null;
1765
+ }
1766
+ }
1767
+ /**
1768
+ * Save roadmap to file
1769
+ */
1770
+ saveRoadmap(roadmap) {
1771
+ const roadmapDir = join5(this.projectPath, ROADMAP_DIR);
1772
+ if (!existsSync3(roadmapDir)) {
1773
+ mkdirSync3(roadmapDir, { recursive: true });
1774
+ }
1775
+ const roadmapPath = join5(roadmapDir, ROADMAP_FILE);
1776
+ writeFileSync5(roadmapPath, JSON.stringify(roadmap, null, 2));
1777
+ }
1778
+ /**
1779
+ * Generate a new roadmap using Claude
1780
+ */
1781
+ async generate(request = {}) {
1782
+ if (this.session) {
1783
+ throw new Error("Roadmap generation already in progress");
1784
+ }
1785
+ this.emit("roadmap:started", {
1786
+ type: "roadmap:started",
1787
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1788
+ });
1789
+ try {
1790
+ this.emit("roadmap:progress", {
1791
+ type: "roadmap:progress",
1792
+ phase: "analyzing",
1793
+ message: "Analyzing project structure..."
1794
+ });
1795
+ const projectInfo = await this.analyzeProject();
1796
+ let competitors;
1797
+ if (request.enableCompetitorResearch) {
1798
+ this.emit("roadmap:progress", {
1799
+ type: "roadmap:progress",
1800
+ phase: "researching",
1801
+ message: "Researching competitors..."
1802
+ });
1803
+ competitors = await this.researchCompetitors(projectInfo);
1804
+ }
1805
+ this.emit("roadmap:progress", {
1806
+ type: "roadmap:progress",
1807
+ phase: "generating",
1808
+ message: "Generating feature roadmap..."
1809
+ });
1810
+ const roadmap = await this.generateRoadmap(projectInfo, competitors, request);
1811
+ this.saveRoadmap(roadmap);
1812
+ this.emit("roadmap:completed", {
1813
+ type: "roadmap:completed",
1814
+ roadmap
1815
+ });
1816
+ } catch (error) {
1817
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1818
+ this.emit("roadmap:failed", {
1819
+ type: "roadmap:failed",
1820
+ error: errorMessage
1821
+ });
1822
+ throw error;
1823
+ } finally {
1824
+ this.session = null;
1825
+ }
1826
+ }
1827
+ /**
1828
+ * Cancel ongoing roadmap generation
1829
+ */
1830
+ cancel() {
1831
+ if (this.session) {
1832
+ this.session.process.kill("SIGTERM");
1833
+ this.session = null;
1834
+ }
1835
+ }
1836
+ /**
1837
+ * Analyze the project structure
1838
+ */
1839
+ async analyzeProject() {
1840
+ const projectName = basename2(this.projectPath);
1841
+ let description = "";
1842
+ let stack = [];
1843
+ let existingFeatures = [];
1844
+ const packageJsonPath = join5(this.projectPath, "package.json");
1845
+ if (existsSync3(packageJsonPath)) {
1846
+ try {
1847
+ const pkg = JSON.parse(readFileSync5(packageJsonPath, "utf-8"));
1848
+ description = pkg.description || "";
1849
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1850
+ if (deps.react) stack.push("React");
1851
+ if (deps.vue) stack.push("Vue");
1852
+ if (deps.angular) stack.push("Angular");
1853
+ if (deps.next) stack.push("Next.js");
1854
+ if (deps.express) stack.push("Express");
1855
+ if (deps.fastify) stack.push("Fastify");
1856
+ if (deps.typescript) stack.push("TypeScript");
1857
+ if (deps.tailwindcss) stack.push("Tailwind CSS");
1858
+ } catch {
1859
+ }
1860
+ }
1861
+ const readmePaths = ["README.md", "readme.md", "README.txt", "readme.txt"];
1862
+ for (const readmePath of readmePaths) {
1863
+ const fullPath = join5(this.projectPath, readmePath);
1864
+ if (existsSync3(fullPath)) {
1865
+ const readme = readFileSync5(fullPath, "utf-8");
1866
+ if (!description) {
1867
+ const lines = readme.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
1868
+ if (lines.length > 0) {
1869
+ description = lines[0].trim().slice(0, 500);
1870
+ }
1871
+ }
1872
+ break;
1873
+ }
1874
+ }
1875
+ const prdPath = join5(this.projectPath, ROADMAP_DIR, "prd.json");
1876
+ if (existsSync3(prdPath)) {
1877
+ try {
1878
+ const prd = JSON.parse(readFileSync5(prdPath, "utf-8"));
1879
+ existingFeatures = prd.tasks?.map((t) => t.title) || [];
1880
+ } catch {
1881
+ }
1882
+ }
1883
+ return {
1884
+ name: projectName,
1885
+ description,
1886
+ stack,
1887
+ existingFeatures
1888
+ };
1889
+ }
1890
+ /**
1891
+ * Research competitors using Claude with web search
1892
+ */
1893
+ async researchCompetitors(projectInfo) {
1894
+ const prompt = this.buildCompetitorResearchPrompt(projectInfo);
1895
+ const result = await this.runClaudeCommand(prompt);
1896
+ try {
1897
+ const jsonMatch = result.match(/```json\n([\s\S]*?)\n```/);
1898
+ if (jsonMatch) {
1899
+ return JSON.parse(jsonMatch[1]);
1900
+ }
1901
+ return JSON.parse(result);
1902
+ } catch {
1903
+ console.error("[roadmap] Failed to parse competitor research:", result);
1904
+ return [];
1905
+ }
1906
+ }
1907
+ /**
1908
+ * Generate the roadmap using Claude
1909
+ */
1910
+ async generateRoadmap(projectInfo, competitors, request) {
1911
+ const prompt = this.buildRoadmapPrompt(projectInfo, competitors, request);
1912
+ const result = await this.runClaudeCommand(prompt);
1913
+ try {
1914
+ const jsonMatch = result.match(/```json\n([\s\S]*?)\n```/);
1915
+ let roadmapData;
1916
+ if (jsonMatch) {
1917
+ roadmapData = JSON.parse(jsonMatch[1]);
1918
+ } else {
1919
+ roadmapData = JSON.parse(result);
1920
+ }
1921
+ const roadmap = {
1922
+ id: `roadmap_${nanoid2(8)}`,
1923
+ projectName: projectInfo.name,
1924
+ projectDescription: roadmapData.projectDescription || projectInfo.description,
1925
+ targetAudience: roadmapData.targetAudience || "Developers",
1926
+ phases: roadmapData.phases.map((p, i) => ({
1927
+ id: `phase_${nanoid2(8)}`,
1928
+ name: p.name,
1929
+ description: p.description,
1930
+ order: i
1931
+ })),
1932
+ features: roadmapData.features.map((f) => ({
1933
+ id: `feature_${nanoid2(8)}`,
1934
+ title: f.title,
1935
+ description: f.description,
1936
+ priority: f.priority,
1937
+ category: f.category || "functional",
1938
+ effort: f.effort || "medium",
1939
+ impact: f.impact || "medium",
1940
+ phase: f.phase,
1941
+ rationale: f.rationale || "",
1942
+ steps: f.steps,
1943
+ addedToKanban: false
1944
+ })),
1945
+ competitors,
1946
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1947
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1948
+ };
1949
+ return roadmap;
1950
+ } catch (error) {
1951
+ console.error("[roadmap] Failed to parse roadmap:", result);
1952
+ throw new Error("Failed to parse roadmap from Claude response");
1953
+ }
1954
+ }
1955
+ /**
1956
+ * Build the competitor research prompt
1957
+ */
1958
+ buildCompetitorResearchPrompt(projectInfo) {
1959
+ return `You are a product research analyst. Research competitors for the following project:
1960
+
1961
+ Project: ${projectInfo.name}
1962
+ Description: ${projectInfo.description}
1963
+ Tech Stack: ${projectInfo.stack.join(", ") || "Unknown"}
1964
+
1965
+ Your task:
1966
+ 1. Use web search to find 3-5 competitors or similar projects
1967
+ 2. Analyze their strengths and weaknesses
1968
+ 3. Identify differentiating features
1969
+
1970
+ Return your findings as JSON in this format:
1971
+ \`\`\`json
1972
+ [
1973
+ {
1974
+ "name": "Competitor Name",
1975
+ "url": "https://example.com",
1976
+ "strengths": ["strength 1", "strength 2"],
1977
+ "weaknesses": ["weakness 1", "weakness 2"],
1978
+ "differentiators": ["feature 1", "feature 2"]
1979
+ }
1980
+ ]
1981
+ \`\`\`
1982
+
1983
+ Only return the JSON, no other text.`;
1984
+ }
1985
+ /**
1986
+ * Build the roadmap generation prompt
964
1987
  */
965
- continueAFKMode() {
966
- if (!this.afkMode) return;
967
- if (this.afkIteration >= this.afkMaxIterations) {
968
- this.stopAFKMode();
969
- return;
1988
+ buildRoadmapPrompt(projectInfo, competitors, request) {
1989
+ let prompt = `You are a product strategist creating a feature roadmap for a software project.
1990
+
1991
+ ## Project Information
1992
+ Name: ${projectInfo.name}
1993
+ Description: ${projectInfo.description}
1994
+ Tech Stack: ${projectInfo.stack.join(", ") || "Unknown"}
1995
+ ${projectInfo.existingFeatures.length > 0 ? `
1996
+ Existing Features/Tasks:
1997
+ ${projectInfo.existingFeatures.map((f) => `- ${f}`).join("\n")}` : ""}
1998
+ `;
1999
+ if (competitors && competitors.length > 0) {
2000
+ prompt += `
2001
+ ## Competitor Analysis
2002
+ ${competitors.map((c) => `
2003
+ ### ${c.name}
2004
+ - Strengths: ${c.strengths.join(", ")}
2005
+ - Weaknesses: ${c.weaknesses.join(", ")}
2006
+ - Key Features: ${c.differentiators.join(", ")}
2007
+ `).join("\n")}
2008
+ `;
970
2009
  }
971
- if (this.isRunning()) return;
972
- const nextTask = getNextReadyTask(this.projectPath);
973
- if (!nextTask) {
974
- this.stopAFKMode();
975
- return;
2010
+ if (request.focusAreas && request.focusAreas.length > 0) {
2011
+ prompt += `
2012
+ ## Focus Areas
2013
+ The user wants to focus on: ${request.focusAreas.join(", ")}
2014
+ `;
976
2015
  }
977
- this.afkIteration++;
978
- this.runTask(nextTask.id).catch((error) => {
979
- console.error("AFK task error:", error);
980
- });
981
- this.emitAFKStatus();
982
- }
983
- /**
984
- * Stop AFK mode
985
- */
986
- stopAFKMode() {
987
- this.afkMode = false;
988
- this.emitAFKStatus();
2016
+ if (request.customPrompt) {
2017
+ prompt += `
2018
+ ## Additional Context
2019
+ ${request.customPrompt}
2020
+ `;
2021
+ }
2022
+ prompt += `
2023
+ ## Your Task
2024
+ Create a comprehensive product roadmap with features organized into phases.
2025
+
2026
+ Use the MoSCoW prioritization framework:
2027
+ - must: Critical features that must be implemented
2028
+ - should: Important features that should be implemented
2029
+ - could: Nice-to-have features that could be implemented
2030
+ - wont: Features that won't be implemented in the near term
2031
+
2032
+ For each feature, estimate:
2033
+ - effort: low, medium, or high
2034
+ - impact: low, medium, or high
2035
+
2036
+ Categories should be one of: functional, ui, bug, enhancement, testing, refactor
2037
+
2038
+ Return your roadmap as JSON:
2039
+ \`\`\`json
2040
+ {
2041
+ "projectDescription": "Brief description of the project",
2042
+ "targetAudience": "Who this project is for",
2043
+ "phases": [
2044
+ {
2045
+ "name": "Phase 1: MVP",
2046
+ "description": "Core features for minimum viable product"
2047
+ },
2048
+ {
2049
+ "name": "Phase 2: Enhancement",
2050
+ "description": "Features to improve user experience"
2051
+ }
2052
+ ],
2053
+ "features": [
2054
+ {
2055
+ "title": "Feature title",
2056
+ "description": "What this feature does",
2057
+ "priority": "must",
2058
+ "category": "functional",
2059
+ "effort": "medium",
2060
+ "impact": "high",
2061
+ "phase": "Phase 1: MVP",
2062
+ "rationale": "Why this feature is important",
2063
+ "steps": ["Step 1", "Step 2"]
2064
+ }
2065
+ ]
2066
+ }
2067
+ \`\`\`
2068
+
2069
+ Generate 10-20 strategic features across 3-4 phases. Be specific and actionable.
2070
+ Don't duplicate features that already exist in the project.
2071
+ Only return the JSON, no other text.`;
2072
+ return prompt;
989
2073
  }
990
2074
  /**
991
- * Emit AFK status
2075
+ * Run a Claude command and return the output
992
2076
  */
993
- emitAFKStatus() {
994
- this.emit("afk:status", {
995
- running: this.afkMode,
996
- currentIteration: this.afkIteration,
997
- maxIterations: this.afkMaxIterations,
998
- tasksCompleted: this.afkTasksCompleted
2077
+ runClaudeCommand(prompt) {
2078
+ return new Promise((resolve, reject) => {
2079
+ const command = this.config?.agent.command || "claude";
2080
+ const permissionMode = this.config?.agent.permissionMode || "auto";
2081
+ const model = this.config?.agent.model;
2082
+ const promptPath = join5(this.projectPath, ROADMAP_DIR, "prompt-roadmap.txt");
2083
+ writeFileSync5(promptPath, prompt);
2084
+ const args = [
2085
+ "--permission-mode",
2086
+ permissionMode,
2087
+ "-p",
2088
+ "--verbose",
2089
+ "--output-format",
2090
+ "text",
2091
+ `@${promptPath}`
2092
+ ];
2093
+ if (model) {
2094
+ args.unshift("--model", model);
2095
+ }
2096
+ console.log(`[roadmap] Command: ${command} ${args.join(" ")}`);
2097
+ const childProcess = spawn2(command, args, {
2098
+ cwd: this.projectPath,
2099
+ stdio: ["pipe", "pipe", "pipe"],
2100
+ env: {
2101
+ ...process.env,
2102
+ FORCE_COLOR: "0"
2103
+ }
2104
+ });
2105
+ this.session = {
2106
+ process: childProcess,
2107
+ startedAt: /* @__PURE__ */ new Date(),
2108
+ output: []
2109
+ };
2110
+ let stdout = "";
2111
+ let stderr = "";
2112
+ childProcess.stdout?.on("data", (data) => {
2113
+ const chunk = data.toString();
2114
+ stdout += chunk;
2115
+ this.session?.output.push(chunk);
2116
+ });
2117
+ childProcess.stderr?.on("data", (data) => {
2118
+ stderr += data.toString();
2119
+ });
2120
+ childProcess.on("close", (code) => {
2121
+ this.session = null;
2122
+ if (code === 0) {
2123
+ resolve(stdout);
2124
+ } else {
2125
+ reject(new Error(`Claude command failed with code ${code}: ${stderr}`));
2126
+ }
2127
+ });
2128
+ childProcess.on("error", (error) => {
2129
+ this.session = null;
2130
+ reject(error);
2131
+ });
999
2132
  });
1000
2133
  }
1001
2134
  /**
1002
- * Get AFK status
2135
+ * Mark a feature as added to kanban
1003
2136
  */
1004
- getAFKStatus() {
1005
- return {
1006
- running: this.afkMode,
1007
- currentIteration: this.afkIteration,
1008
- maxIterations: this.afkMaxIterations,
1009
- tasksCompleted: this.afkTasksCompleted
1010
- };
2137
+ markFeatureAdded(featureId) {
2138
+ const roadmap = this.getRoadmap();
2139
+ if (!roadmap) return null;
2140
+ const feature = roadmap.features.find((f) => f.id === featureId);
2141
+ if (feature) {
2142
+ feature.addedToKanban = true;
2143
+ roadmap.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2144
+ this.saveRoadmap(roadmap);
2145
+ }
2146
+ return roadmap;
1011
2147
  }
1012
2148
  /**
1013
- * Cancel running task and stop AFK mode
2149
+ * Delete a feature from the roadmap
1014
2150
  */
1015
- cancelAll() {
1016
- if (this.runningTask) {
1017
- try {
1018
- this.runningTask.process.kill("SIGKILL");
1019
- } catch {
1020
- }
1021
- this.runningTask = null;
2151
+ deleteFeature(featureId) {
2152
+ const roadmap = this.getRoadmap();
2153
+ if (!roadmap) return null;
2154
+ const index = roadmap.features.findIndex((f) => f.id === featureId);
2155
+ if (index !== -1) {
2156
+ roadmap.features.splice(index, 1);
2157
+ roadmap.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2158
+ this.saveRoadmap(roadmap);
1022
2159
  }
1023
- this.stopAFKMode();
2160
+ return roadmap;
1024
2161
  }
1025
2162
  };
1026
2163
 
@@ -1327,7 +2464,7 @@ function getTemplateById(id) {
1327
2464
  }
1328
2465
 
1329
2466
  // src/server/services/ai.ts
1330
- import { spawn as spawn2 } from "child_process";
2467
+ import { spawn as spawn3 } from "child_process";
1331
2468
  async function generateTaskFromPrompt(projectPath, userPrompt) {
1332
2469
  const config = getConfig(projectPath);
1333
2470
  const systemPrompt = `You are a task generator for a Kanban board used in software development.
@@ -1366,7 +2503,7 @@ User request: ${userPrompt}`;
1366
2503
  }
1367
2504
  let output = "";
1368
2505
  let errorOutput = "";
1369
- const proc = spawn2(config.agent.command, args, {
2506
+ const proc = spawn3(config.agent.command, args, {
1370
2507
  cwd: projectPath,
1371
2508
  shell: true,
1372
2509
  env: { ...process.env }
@@ -1438,12 +2575,22 @@ async function createServer(projectPath, port) {
1438
2575
  });
1439
2576
  app.use(express.json());
1440
2577
  const executor = new TaskExecutor(projectPath);
2578
+ const roadmapService = new RoadmapService(projectPath);
1441
2579
  executor.on("task:started", (data) => io.emit("task:started", data));
1442
2580
  executor.on("task:output", (data) => io.emit("task:output", data));
1443
2581
  executor.on("task:completed", (data) => io.emit("task:completed", data));
1444
2582
  executor.on("task:failed", (data) => io.emit("task:failed", data));
1445
2583
  executor.on("task:cancelled", (data) => io.emit("task:cancelled", data));
1446
2584
  executor.on("afk:status", (data) => io.emit("afk:status", data));
2585
+ executor.on("planning:started", (data) => io.emit("planning:started", data));
2586
+ executor.on("planning:output", (data) => io.emit("planning:output", data));
2587
+ executor.on("planning:completed", (data) => io.emit("planning:completed", data));
2588
+ executor.on("planning:failed", (data) => io.emit("planning:failed", data));
2589
+ executor.on("planning:cancelled", (data) => io.emit("planning:cancelled", data));
2590
+ roadmapService.on("roadmap:started", (data) => io.emit("roadmap:started", data));
2591
+ roadmapService.on("roadmap:progress", (data) => io.emit("roadmap:progress", data));
2592
+ roadmapService.on("roadmap:completed", (data) => io.emit("roadmap:completed", data));
2593
+ roadmapService.on("roadmap:failed", (data) => io.emit("roadmap:failed", data));
1447
2594
  app.get("/api/tasks", (_req, res) => {
1448
2595
  try {
1449
2596
  const tasks = getAllTasks(projectPath);
@@ -1658,6 +2805,40 @@ async function createServer(projectPath, port) {
1658
2805
  res.status(500).json({ error: String(error) });
1659
2806
  }
1660
2807
  });
2808
+ app.post("/api/plan", async (req, res) => {
2809
+ try {
2810
+ const { goal } = req.body;
2811
+ if (!goal) {
2812
+ res.status(400).json({ error: "Goal is required" });
2813
+ return;
2814
+ }
2815
+ await executor.runPlanningSession(goal);
2816
+ res.json({ success: true });
2817
+ } catch (error) {
2818
+ res.status(400).json({ error: String(error) });
2819
+ }
2820
+ });
2821
+ app.post("/api/plan/cancel", (_req, res) => {
2822
+ try {
2823
+ const cancelled = executor.cancelPlanning();
2824
+ if (!cancelled) {
2825
+ res.status(404).json({ error: "No planning session running" });
2826
+ return;
2827
+ }
2828
+ res.json({ success: true });
2829
+ } catch (error) {
2830
+ res.status(500).json({ error: String(error) });
2831
+ }
2832
+ });
2833
+ app.get("/api/plan/status", (_req, res) => {
2834
+ try {
2835
+ const planning = executor.isPlanning();
2836
+ const goal = executor.getPlanningGoal();
2837
+ res.json({ planning, goal: goal || null });
2838
+ } catch (error) {
2839
+ res.status(500).json({ error: String(error) });
2840
+ }
2841
+ });
1661
2842
  app.get("/api/running", (_req, res) => {
1662
2843
  try {
1663
2844
  const taskId = executor.getRunningTaskId();
@@ -1671,13 +2852,104 @@ async function createServer(projectPath, port) {
1671
2852
  const counts = getTaskCounts(projectPath);
1672
2853
  const running = executor.isRunning() ? 1 : 0;
1673
2854
  const afk = executor.getAFKStatus();
1674
- res.json({ counts, running, afk });
2855
+ const planning = executor.isPlanning();
2856
+ res.json({ counts, running, afk, planning });
2857
+ } catch (error) {
2858
+ res.status(500).json({ error: String(error) });
2859
+ }
2860
+ });
2861
+ app.get("/api/roadmap", (_req, res) => {
2862
+ try {
2863
+ const roadmap = roadmapService.getRoadmap();
2864
+ res.json({ roadmap });
2865
+ } catch (error) {
2866
+ res.status(500).json({ error: String(error) });
2867
+ }
2868
+ });
2869
+ app.post("/api/roadmap/generate", async (req, res) => {
2870
+ try {
2871
+ const request = req.body;
2872
+ if (roadmapService.isRunning()) {
2873
+ res.status(400).json({ error: "Roadmap generation already in progress" });
2874
+ return;
2875
+ }
2876
+ roadmapService.generate(request).catch(console.error);
2877
+ res.json({ success: true, message: "Roadmap generation started" });
2878
+ } catch (error) {
2879
+ res.status(500).json({ error: String(error) });
2880
+ }
2881
+ });
2882
+ app.post("/api/roadmap/cancel", (_req, res) => {
2883
+ try {
2884
+ if (!roadmapService.isRunning()) {
2885
+ res.status(400).json({ error: "No roadmap generation in progress" });
2886
+ return;
2887
+ }
2888
+ roadmapService.cancel();
2889
+ res.json({ success: true });
2890
+ } catch (error) {
2891
+ res.status(500).json({ error: String(error) });
2892
+ }
2893
+ });
2894
+ app.post("/api/roadmap/features/:id/add-to-kanban", (req, res) => {
2895
+ try {
2896
+ const roadmap = roadmapService.getRoadmap();
2897
+ if (!roadmap) {
2898
+ res.status(404).json({ error: "No roadmap found" });
2899
+ return;
2900
+ }
2901
+ const feature = roadmap.features.find((f) => f.id === req.params.id);
2902
+ if (!feature) {
2903
+ res.status(404).json({ error: "Feature not found" });
2904
+ return;
2905
+ }
2906
+ if (feature.addedToKanban) {
2907
+ res.status(400).json({ error: "Feature already added to kanban" });
2908
+ return;
2909
+ }
2910
+ const priorityMap = {
2911
+ must: "critical",
2912
+ should: "high",
2913
+ could: "medium",
2914
+ wont: "low"
2915
+ };
2916
+ const task = createTask(projectPath, {
2917
+ title: feature.title,
2918
+ description: feature.description,
2919
+ category: feature.category,
2920
+ priority: priorityMap[feature.priority] || "medium",
2921
+ steps: feature.steps || [],
2922
+ status: "draft"
2923
+ });
2924
+ roadmapService.markFeatureAdded(req.params.id);
2925
+ io.emit("task:created", task);
2926
+ res.json({ task });
2927
+ } catch (error) {
2928
+ res.status(500).json({ error: String(error) });
2929
+ }
2930
+ });
2931
+ app.delete("/api/roadmap/features/:id", (req, res) => {
2932
+ try {
2933
+ const roadmap = roadmapService.deleteFeature(req.params.id);
2934
+ if (!roadmap) {
2935
+ res.status(404).json({ error: "Roadmap or feature not found" });
2936
+ return;
2937
+ }
2938
+ io.emit("roadmap:updated", roadmap);
2939
+ res.json({ success: true });
2940
+ } catch (error) {
2941
+ res.status(500).json({ error: String(error) });
2942
+ }
2943
+ });
2944
+ app.get("/api/roadmap/status", (_req, res) => {
2945
+ try {
2946
+ res.json({ generating: roadmapService.isRunning() });
1675
2947
  } catch (error) {
1676
2948
  res.status(500).json({ error: String(error) });
1677
2949
  }
1678
2950
  });
1679
- const clientPath = join5(__dirname2, "..", "client");
1680
- if (existsSync3(clientPath)) {
2951
+ const clientPath = join6(__dirname2, "..", "client");
2952
+ if (existsSync4(clientPath)) {
1681
2953
  app.use(express.static(clientPath));
1682
2954
  }
1683
2955
  app.get("*", (_req, res) => {
@@ -1698,8 +2970,11 @@ async function createServer(projectPath, port) {
1698
2970
  tasks: getAllTasks(projectPath),
1699
2971
  running: runningIds,
1700
2972
  afk: executor.getAFKStatus(),
1701
- taskLogs
2973
+ taskLogs,
1702
2974
  // Include logs for running task
2975
+ planning: executor.isPlanning(),
2976
+ planningGoal: executor.getPlanningGoal() || null,
2977
+ planningOutput: executor.getPlanningOutput() || []
1703
2978
  });
1704
2979
  socket.on("get-logs", (taskId) => {
1705
2980
  const logs = executor.getTaskLog(taskId);
@@ -1929,7 +3204,7 @@ function getClientHTML() {
1929
3204
  transition: all 0.2s var(--ease-out-expo);
1930
3205
  }
1931
3206
  .side-panel-header {
1932
- padding: 16px 20px;
3207
+ padding: 12px 16px;
1933
3208
  border-bottom: 1px solid #e5e5e5;
1934
3209
  flex-shrink: 0;
1935
3210
  }
@@ -1947,7 +3222,8 @@ function getClientHTML() {
1947
3222
  flex-shrink: 0;
1948
3223
  }
1949
3224
  .side-panel-tab {
1950
- padding: 12px 16px;
3225
+ padding: 10px 14px;
3226
+ font-size: 13px;
1951
3227
  color: #737373;
1952
3228
  cursor: pointer;
1953
3229
  border-bottom: 2px solid transparent;
@@ -2240,6 +3516,10 @@ let state = {
2240
3516
  logFullscreen: false,
2241
3517
  closedTabs: new Set(),
2242
3518
  sidePanel: null, // task id for side panel
3519
+ // Planning state
3520
+ planning: false,
3521
+ planningGoal: '',
3522
+ planningOutput: [],
2243
3523
  sidePanelTab: 'logs', // 'logs' or 'details'
2244
3524
  darkMode: localStorage.getItem('darkMode') === 'true', // Add dark mode state
2245
3525
  };
@@ -2334,6 +3614,14 @@ socket.on('init', (data) => {
2334
3614
  state.running = data.running;
2335
3615
  state.afk = data.afk;
2336
3616
 
3617
+ // Planning state
3618
+ state.planning = data.planning || false;
3619
+ state.planningGoal = data.planningGoal || '';
3620
+ state.planningOutput = (data.planningOutput || []).map(text => ({
3621
+ text: text,
3622
+ timestamp: new Date().toISOString()
3623
+ }));
3624
+
2337
3625
  // Load persisted logs for running tasks
2338
3626
  if (data.taskLogs) {
2339
3627
  for (const [taskId, logs] of Object.entries(data.taskLogs)) {
@@ -2438,6 +3726,48 @@ socket.on('afk:status', (status) => {
2438
3726
  render();
2439
3727
  });
2440
3728
 
3729
+ // Planning socket handlers
3730
+ socket.on('planning:started', ({ goal, timestamp }) => {
3731
+ state.planning = true;
3732
+ state.planningGoal = goal;
3733
+ state.planningOutput = [];
3734
+ state.showModal = 'planning';
3735
+ showToast('Planning started: ' + goal.substring(0, 50) + (goal.length > 50 ? '...' : ''), 'info');
3736
+ render();
3737
+ });
3738
+
3739
+ socket.on('planning:output', ({ line }) => {
3740
+ state.planningOutput.push({
3741
+ text: line,
3742
+ timestamp: new Date().toISOString()
3743
+ });
3744
+ render();
3745
+ });
3746
+
3747
+ socket.on('planning:completed', () => {
3748
+ state.planning = false;
3749
+ showToast('Planning complete! New tasks added to board.', 'success');
3750
+ // Refresh tasks from server
3751
+ fetch('/api/tasks').then(r => r.json()).then(data => {
3752
+ state.tasks = data.tasks;
3753
+ state.showModal = null;
3754
+ render();
3755
+ });
3756
+ });
3757
+
3758
+ socket.on('planning:failed', ({ error }) => {
3759
+ state.planning = false;
3760
+ showToast('Planning failed: ' + error, 'error');
3761
+ render();
3762
+ });
3763
+
3764
+ socket.on('planning:cancelled', () => {
3765
+ state.planning = false;
3766
+ state.showModal = null;
3767
+ showToast('Planning cancelled', 'warning');
3768
+ render();
3769
+ });
3770
+
2441
3771
  // Load templates
2442
3772
  fetch('/api/templates').then(r => r.json()).then(data => {
2443
3773
  state.templates = data.templates;
@@ -2517,6 +3847,18 @@ async function stopAFK() {
2517
3847
  await fetch('/api/afk/stop', { method: 'POST' });
2518
3848
  }
2519
3849
 
3850
+ async function startPlanning(goal) {
3851
+ await fetch('/api/plan', {
3852
+ method: 'POST',
3853
+ headers: { 'Content-Type': 'application/json' },
3854
+ body: JSON.stringify({ goal })
3855
+ });
3856
+ }
3857
+
3858
+ async function cancelPlanning() {
3859
+ await fetch('/api/plan/cancel', { method: 'POST' });
3860
+ }
3861
+
2520
3862
  // Enhanced Drag and drop
2521
3863
  let draggedTask = null;
2522
3864
  let draggedElement = null;
@@ -2661,6 +4003,11 @@ function openSidePanel(taskId) {
2661
4003
  state.activeTab = taskId;
2662
4004
  state.closedTabs.delete(taskId);
2663
4005
 
4006
+ // Auto-switch to logs tab if task is running
4007
+ if (state.running.includes(taskId)) {
4008
+ state.sidePanelTab = 'logs';
4009
+ }
4010
+
2664
4011
  // Request logs from server if not already loaded
2665
4012
  if (!state.taskOutput[taskId] || state.taskOutput[taskId].length === 0) {
2666
4013
  socket.emit('get-logs', taskId);
@@ -2965,6 +4312,71 @@ function renderModal() {
2965
4312
  \`;
2966
4313
  }
2967
4314
 
4315
+ // Planning input modal
4316
+ if (state.showModal === 'plan-input') {
4317
+ return \`
4318
+ <div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50" onclick="if(event.target === event.currentTarget) { state.showModal = null; render(); }">
4319
+ <div class="modal-content card rounded-xl w-full max-w-lg mx-4">
4320
+ <div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
4321
+ <h3 class="font-display font-semibold text-canvas-800 text-lg">\u{1F3AF} AI Task Planner</h3>
4322
+ <button onclick="state.showModal = null; render();" class="btn btn-ghost p-1.5 text-canvas-500 hover:text-canvas-700">\u2715</button>
4323
+ </div>
4324
+ <div class="p-6">
4325
+ <p class="text-sm text-canvas-600 mb-4">Describe your goal and the AI will analyze the codebase and break it down into concrete tasks.</p>
4326
+ <div class="space-y-4">
4327
+ <div>
4328
+ <label class="block text-sm font-medium text-canvas-700 mb-2">What do you want to build?</label>
4329
+ <textarea id="planning-goal" rows="4" placeholder="e.g., Add user authentication with JWT tokens, login/logout functionality, and protected routes..."
4330
+ class="input w-full resize-none"></textarea>
4331
+ </div>
4332
+ <div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
4333
+ <p class="text-xs text-blue-700">\u{1F4A1} Be specific about what you want. The AI will explore your codebase and create 3-8 tasks with implementation guidance.</p>
4334
+ </div>
4335
+ </div>
4336
+ <div class="flex justify-end gap-3 mt-6">
4337
+ <button onclick="state.showModal = null; render();"
4338
+ class="btn btn-ghost px-4 py-2.5">Cancel</button>
4339
+ <button onclick="handleStartPlanning()"
4340
+ class="btn px-5 py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium">\u{1F680} Start Planning</button>
4341
+ </div>
4342
+ </div>
4343
+ </div>
4344
+ </div>
4345
+ \`;
4346
+ }
4347
+
4348
+ // Planning progress modal
4349
+ if (state.showModal === 'planning') {
4350
+ const outputHtml = state.planningOutput.length > 0
4351
+ ? state.planningOutput.map(l => \`<div class="log-line">\${highlightLog(l.text || l)}</div>\`).join('')
4352
+ : '<div class="text-canvas-400 text-sm">Analyzing codebase and generating tasks...</div>';
4353
+
4354
+ return \`
4355
+ <div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50">
4356
+ <div class="modal-content card rounded-xl w-full max-w-3xl mx-4 max-h-[80vh] flex flex-col">
4357
+ <div class="px-6 py-4 border-b border-white/5 flex justify-between items-center flex-shrink-0">
4358
+ <div class="flex items-center gap-3">
4359
+ <span class="text-xl animate-pulse">\u{1F3AF}</span>
4360
+ <div>
4361
+ <h3 class="font-display font-semibold text-canvas-800 text-lg">Planning in Progress</h3>
4362
+ <p class="text-xs text-canvas-500 truncate max-w-[400px]">\${escapeHtml(state.planningGoal)}</p>
4363
+ </div>
4364
+ </div>
4365
+ <button onclick="if(confirm('Cancel planning?')) cancelPlanning();"
4366
+ class="btn btn-ghost px-3 py-1.5 text-sm text-status-failed hover:bg-status-failed/10">
4367
+ \u23F9 Cancel
4368
+ </button>
4369
+ </div>
4370
+ <div class="flex-1 overflow-hidden p-4">
4371
+ <div class="log-container h-full overflow-y-auto" id="planning-log">
4372
+ \${outputHtml}
4373
+ </div>
4374
+ </div>
4375
+ </div>
4376
+ </div>
4377
+ \`;
4378
+ }
4379
+
2968
4380
  return '';
2969
4381
  }
2970
4382
 
@@ -2978,100 +4390,55 @@ function renderSidePanel() {
2978
4390
  const output = state.taskOutput[task.id] || [];
2979
4391
  const startTime = state.taskStartTime[task.id];
2980
4392
  const lastExec = task.executionHistory?.[task.executionHistory.length - 1];
4393
+ const elapsed = isRunning && startTime ? formatElapsed(startTime) : null;
2981
4394
 
2982
4395
  return \`
2983
4396
  <div class="side-panel">
2984
- <!-- Header -->
2985
- <div class="side-panel-header">
2986
- <div class="flex justify-between items-start">
2987
- <div class="flex-1 pr-4">
2988
- <h2 class="font-semibold text-canvas-900 text-lg leading-tight">\${escapeHtml(task.title)}</h2>
2989
- <div class="flex items-center gap-2 mt-2">
2990
- <span class="status-badge status-badge-\${task.status}">
4397
+ <!-- Compact Header -->
4398
+ <div class="side-panel-header" style="padding: 12px 16px;">
4399
+ <div class="flex justify-between items-start gap-2">
4400
+ <div class="flex-1 min-w-0">
4401
+ <h2 class="font-semibold text-canvas-900 text-base leading-tight truncate" title="\${escapeHtml(task.title)}">\${escapeHtml(task.title)}</h2>
4402
+ <div class="flex items-center gap-2 mt-1.5 flex-wrap">
4403
+ <span class="status-badge status-badge-\${task.status}" style="font-size: 11px; padding: 2px 8px;">
2991
4404
  <span class="w-1.5 h-1.5 rounded-full bg-current \${isRunning ? 'animate-pulse' : ''}"></span>
2992
4405
  \${task.status.replace('_', ' ')}
2993
4406
  </span>
4407
+ \${elapsed ? \`<span class="text-xs text-canvas-500">\u23F1 \${elapsed}</span>\` : ''}
4408
+ <span class="text-xs text-canvas-400">\${task.priority} \xB7 \${task.category}</span>
2994
4409
  </div>
2995
4410
  </div>
2996
- <div class="flex items-center gap-1">
4411
+ <div class="flex items-center gap-0.5 flex-shrink-0">
4412
+ \${task.status === 'in_progress' ? \`
4413
+ <button onclick="cancelTask('\${task.id}')" class="btn btn-ghost p-1.5 text-status-failed hover:bg-status-failed/10" title="Stop">
4414
+ \u23F9
4415
+ </button>
4416
+ \` : task.status === 'ready' ? \`
4417
+ <button onclick="runTask('\${task.id}')" class="btn btn-ghost p-1.5 text-status-success hover:bg-status-success/10" title="Run">
4418
+ \u25B6
4419
+ </button>
4420
+ \` : task.status === 'draft' ? \`
4421
+ <button onclick="updateTask('\${task.id}', { status: 'ready' })" class="btn btn-ghost p-1.5 text-blue-500 hover:bg-blue-50" title="Move to Ready">
4422
+ \u2192
4423
+ </button>
4424
+ \` : task.status === 'failed' ? \`
4425
+ <button onclick="retryTask('\${task.id}')" class="btn btn-ghost p-1.5 text-canvas-500 hover:bg-canvas-100" title="Retry">
4426
+ \u21BB
4427
+ </button>
4428
+ \` : ''}
2997
4429
  <button onclick="state.editingTask = state.tasks.find(t => t.id === '\${task.id}'); state.showModal = 'edit'; render();"
2998
- class="btn btn-ghost p-2 text-canvas-500 hover:text-canvas-700" title="Edit">
4430
+ class="btn btn-ghost p-1.5 text-canvas-400 hover:text-canvas-600" title="Edit">
2999
4431
  \u270F\uFE0F
3000
4432
  </button>
3001
- <button onclick="if(confirm('Delete this task?')) { deleteTask('\${task.id}'); closeSidePanel(); }"
3002
- class="btn btn-ghost p-2 text-canvas-500 hover:text-status-failed" title="Delete">
3003
- \u{1F5D1}\uFE0F
3004
- </button>
3005
- <button onclick="closeSidePanel()" class="btn btn-ghost p-2 text-canvas-500 hover:text-canvas-700" title="Close">
4433
+ <button onclick="closeSidePanel()" class="btn btn-ghost p-1.5 text-canvas-400 hover:text-canvas-600" title="Close">
3006
4434
  \u2715
3007
4435
  </button>
3008
4436
  </div>
3009
4437
  </div>
3010
4438
  </div>
3011
4439
 
3012
- <!-- Description -->
3013
- <div class="px-5 py-4 border-b border-canvas-200 flex-shrink-0">
3014
- <p class="text-sm text-canvas-600 leading-relaxed">\${escapeHtml(task.description)}</p>
3015
- \${task.steps && task.steps.length > 0 ? \`
3016
- <div class="mt-3">
3017
- <div class="text-xs font-medium text-canvas-500 mb-2">Steps:</div>
3018
- <ul class="text-sm text-canvas-600 space-y-1">
3019
- \${task.steps.map(s => \`<li class="flex gap-2"><span class="text-canvas-400">\u2022</span>\${escapeHtml(s)}</li>\`).join('')}
3020
- </ul>
3021
- </div>
3022
- \` : ''}
3023
- </div>
3024
-
3025
- <!-- Task Details Grid -->
3026
- <div class="details-grid">
3027
- <div class="details-item">
3028
- <span class="details-label">Priority</span>
3029
- <span class="details-value capitalize">\${task.priority}</span>
3030
- </div>
3031
- <div class="details-item">
3032
- <span class="details-label">Category</span>
3033
- <span class="details-value">\${categoryIcons[task.category] || ''} \${task.category}</span>
3034
- </div>
3035
- \${startTime || lastExec ? \`
3036
- <div class="details-item">
3037
- <span class="details-label">Started</span>
3038
- <span class="details-value">\${new Date(startTime || lastExec?.startedAt).toLocaleString()}</span>
3039
- </div>
3040
- \` : ''}
3041
- \${lastExec?.duration ? \`
3042
- <div class="details-item">
3043
- <span class="details-label">Duration</span>
3044
- <span class="details-value">\${Math.round(lastExec.duration / 1000)}s</span>
3045
- </div>
3046
- \` : ''}
3047
- </div>
3048
-
3049
- <!-- Action Buttons -->
3050
- <div class="px-5 py-4 border-b border-canvas-200 flex flex-wrap gap-2 flex-shrink-0">
3051
- \${task.status === 'draft' ? \`
3052
- <button onclick="updateTask('\${task.id}', { status: 'ready' })" class="btn btn-primary px-4 py-2 text-sm">
3053
- \u2192 Move to Ready
3054
- </button>
3055
- \` : ''}
3056
- \${task.status === 'ready' ? \`
3057
- <button onclick="runTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
3058
- \u25B6 Run Task
3059
- </button>
3060
- \` : ''}
3061
- \${task.status === 'in_progress' ? \`
3062
- <button onclick="cancelTask('\${task.id}')" class="btn btn-danger px-4 py-2 text-sm">
3063
- \u23F9 Stop Attempt
3064
- </button>
3065
- \` : ''}
3066
- \${task.status === 'failed' ? \`
3067
- <button onclick="retryTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
3068
- \u21BB Retry
3069
- </button>
3070
- \` : ''}
3071
- </div>
3072
-
3073
- <!-- Tabs -->
3074
- <div class="side-panel-tabs">
4440
+ <!-- Tabs (moved up, right after header) -->
4441
+ <div class="side-panel-tabs" style="padding: 0 12px;">
3075
4442
  <div class="side-panel-tab \${state.sidePanelTab === 'logs' ? 'active' : ''}" onclick="state.sidePanelTab = 'logs'; render();">
3076
4443
  \u{1F4CB} Logs
3077
4444
  </div>
@@ -3081,9 +4448,9 @@ function renderSidePanel() {
3081
4448
  </div>
3082
4449
 
3083
4450
  <!-- Tab Content -->
3084
- <div class="side-panel-body">
4451
+ <div class="side-panel-body" style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
3085
4452
  \${state.sidePanelTab === 'logs' ? \`
3086
- <div class="log-container" id="side-panel-log">
4453
+ <div class="log-container" id="side-panel-log" style="flex: 1; overflow-y: auto;">
3087
4454
  \${output.length > 0
3088
4455
  ? output.map((l, i) => {
3089
4456
  const text = l.text || l;
@@ -3093,34 +4460,62 @@ function renderSidePanel() {
3093
4460
  }
3094
4461
  </div>
3095
4462
  \` : \`
3096
- <div class="p-5">
3097
- <div class="text-sm text-canvas-600">
3098
- <div class="mb-4">
3099
- <div class="text-xs font-medium text-canvas-500 mb-1">Created</div>
3100
- <div>\${new Date(task.createdAt).toLocaleString()}</div>
4463
+ <div style="flex: 1; overflow-y: auto; padding: 16px;">
4464
+ <!-- Description -->
4465
+ <div class="mb-5">
4466
+ <div class="text-xs font-semibold text-canvas-500 uppercase tracking-wide mb-2">Description</div>
4467
+ <div class="text-sm text-canvas-700 leading-relaxed bg-canvas-50 rounded-lg p-3 border border-canvas-200">
4468
+ \${escapeHtml(task.description || 'No description provided.')}
3101
4469
  </div>
3102
- <div class="mb-4">
3103
- <div class="text-xs font-medium text-canvas-500 mb-1">Last Updated</div>
3104
- <div>\${new Date(task.updatedAt).toLocaleString()}</div>
4470
+ </div>
4471
+
4472
+ <!-- Steps -->
4473
+ \${task.steps && task.steps.length > 0 ? \`
4474
+ <div class="mb-5">
4475
+ <div class="text-xs font-semibold text-canvas-500 uppercase tracking-wide mb-2">Steps (\${task.steps.length})</div>
4476
+ <div class="bg-canvas-50 rounded-lg border border-canvas-200 divide-y divide-canvas-200">
4477
+ \${task.steps.map((step, i) => \`
4478
+ <div class="flex gap-3 p-3 text-sm">
4479
+ <span class="flex-shrink-0 w-5 h-5 rounded-full bg-canvas-200 text-canvas-500 flex items-center justify-center text-xs font-medium">\${i + 1}</span>
4480
+ <span class="text-canvas-700">\${escapeHtml(step)}</span>
4481
+ </div>
4482
+ \`).join('')}
4483
+ </div>
4484
+ </div>
4485
+ \` : ''}
4486
+
4487
+ <!-- Metadata -->
4488
+ <div class="mb-5 grid grid-cols-2 gap-3">
4489
+ <div class="bg-canvas-50 rounded-lg p-3 border border-canvas-200">
4490
+ <div class="text-xs text-canvas-500 mb-1">Created</div>
4491
+ <div class="text-sm text-canvas-700">\${new Date(task.createdAt).toLocaleDateString()}</div>
3105
4492
  </div>
3106
- \${task.executionHistory && task.executionHistory.length > 0 ? \`
3107
- <div>
3108
- <div class="text-xs font-medium text-canvas-500 mb-2">Execution History</div>
3109
- <div class="space-y-2">
3110
- \${task.executionHistory.slice(-5).reverse().map(exec => \`
3111
- <div class="bg-canvas-100 rounded p-2 text-xs">
3112
- <div class="flex justify-between">
3113
- <span class="\${exec.status === 'completed' ? 'text-status-success' : 'text-status-failed'}">\${exec.status}</span>
3114
- <span class="text-canvas-500">\${Math.round(exec.duration / 1000)}s</span>
3115
- </div>
3116
- <div class="text-canvas-500 mt-1">\${new Date(exec.startedAt).toLocaleString()}</div>
3117
- \${exec.error ? \`<div class="text-status-failed mt-1">\${escapeHtml(exec.error)}</div>\` : ''}
4493
+ <div class="bg-canvas-50 rounded-lg p-3 border border-canvas-200">
4494
+ <div class="text-xs text-canvas-500 mb-1">Updated</div>
4495
+ <div class="text-sm text-canvas-700">\${new Date(task.updatedAt).toLocaleDateString()}</div>
4496
+ </div>
4497
+ </div>
4498
+
4499
+ <!-- Execution History -->
4500
+ \${task.executionHistory && task.executionHistory.length > 0 ? \`
4501
+ <div>
4502
+ <div class="text-xs font-semibold text-canvas-500 uppercase tracking-wide mb-2">Execution History</div>
4503
+ <div class="space-y-2">
4504
+ \${task.executionHistory.slice(-5).reverse().map(exec => \`
4505
+ <div class="bg-canvas-50 rounded-lg p-3 border border-canvas-200">
4506
+ <div class="flex justify-between items-center">
4507
+ <span class="text-sm font-medium \${exec.status === 'completed' ? 'text-status-success' : 'text-status-failed'}">
4508
+ \${exec.status === 'completed' ? '\u2713' : '\u2717'} \${exec.status}
4509
+ </span>
4510
+ <span class="text-xs text-canvas-500">\${Math.round(exec.duration / 1000)}s</span>
3118
4511
  </div>
3119
- \`).join('')}
3120
- </div>
4512
+ <div class="text-xs text-canvas-500 mt-1">\${new Date(exec.startedAt).toLocaleString()}</div>
4513
+ \${exec.error ? \`<div class="text-xs text-status-failed mt-2 bg-status-failed/5 rounded p-2">\${escapeHtml(exec.error)}</div>\` : ''}
4514
+ </div>
4515
+ \`).join('')}
3121
4516
  </div>
3122
- \` : ''}
3123
- </div>
4517
+ </div>
4518
+ \` : ''}
3124
4519
  </div>
3125
4520
  \`}
3126
4521
  </div>
@@ -3262,6 +4657,25 @@ function handleStartAFK() {
3262
4657
  render();
3263
4658
  }
3264
4659
 
4660
+ async function handleStartPlanning() {
4661
+ const goal = document.getElementById('planning-goal').value;
4662
+ if (!goal.trim()) {
4663
+ showToast('Please enter a goal', 'warning');
4664
+ return;
4665
+ }
4666
+ try {
4667
+ await startPlanning(goal);
4668
+ // Modal will be shown when planning:started event is received
4669
+ } catch (e) {
4670
+ showToast('Failed to start planning: ' + e.message, 'error');
4671
+ }
4672
+ }
4673
+
4674
+ function openPlanningModal() {
4675
+ state.showModal = 'plan-input';
4676
+ render();
4677
+ }
4678
+
3265
4679
  // Filter tasks based on search query
3266
4680
  function filterTasks(tasks) {
3267
4681
  if (!state.searchQuery) return tasks;
@@ -3300,6 +4714,12 @@ function render() {
3300
4714
  </div>
3301
4715
  </div>
3302
4716
  <div class="flex items-center gap-2">
4717
+ <button onclick="openPlanningModal();"
4718
+ class="btn px-4 py-2 text-sm bg-blue-500 hover:bg-blue-600 text-white \${state.planning ? 'opacity-50 cursor-not-allowed' : ''}"
4719
+ \${state.planning ? 'disabled' : ''}
4720
+ title="AI Task Planner">
4721
+ \u{1F3AF} \${state.planning ? 'Planning...' : 'Plan'}
4722
+ </button>
3303
4723
  <button onclick="state.showModal = 'new'; render();"
3304
4724
  class="btn btn-primary px-4 py-2 text-sm">
3305
4725
  + Add Task
@@ -3366,6 +4786,10 @@ window.retryTask = retryTask;
3366
4786
  window.generateTask = generateTask;
3367
4787
  window.startAFK = startAFK;
3368
4788
  window.stopAFK = stopAFK;
4789
+ window.startPlanning = startPlanning;
4790
+ window.cancelPlanning = cancelPlanning;
4791
+ window.handleStartPlanning = handleStartPlanning;
4792
+ window.openPlanningModal = openPlanningModal;
3369
4793
  window.handleDragStart = handleDragStart;
3370
4794
  window.handleDragEnd = handleDragEnd;
3371
4795
  window.handleDragOver = handleDragOver;
@@ -3462,7 +4886,7 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
3462
4886
 
3463
4887
  // src/bin/cli.ts
3464
4888
  function isGitRepo(dir) {
3465
- return existsSync4(join6(dir, ".git"));
4889
+ return existsSync5(join7(dir, ".git"));
3466
4890
  }
3467
4891
  function hasGitRemote(dir) {
3468
4892
  try {