claude-kanban 0.5.1 → 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 +1023 -36
- package/dist/bin/cli.js.map +1 -1
- package/dist/server/index.js +1015 -32
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
5
|
+
import { join as join6, dirname } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { existsSync as
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
815
|
-
|
|
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
|
*/
|
|
@@ -924,7 +1363,7 @@ When done, output: <promise>PLANNING_COMPLETE</promise>`;
|
|
|
924
1363
|
};
|
|
925
1364
|
}
|
|
926
1365
|
/**
|
|
927
|
-
* Cancel running task/planning and stop AFK mode
|
|
1366
|
+
* Cancel running task/planning/QA and stop AFK mode
|
|
928
1367
|
*/
|
|
929
1368
|
cancelAll() {
|
|
930
1369
|
if (this.runningTask) {
|
|
@@ -941,10 +1380,459 @@ When done, output: <promise>PLANNING_COMPLETE</promise>`;
|
|
|
941
1380
|
}
|
|
942
1381
|
this.planningSession = null;
|
|
943
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();
|
|
944
1392
|
this.stopAFKMode();
|
|
945
1393
|
}
|
|
946
1394
|
};
|
|
947
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
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Build the competitor research prompt
|
|
1629
|
+
*/
|
|
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.`;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Build the roadmap generation prompt
|
|
1659
|
+
*/
|
|
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;
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Run a Claude command and return the output
|
|
1748
|
+
*/
|
|
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
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Mark a feature as added to kanban
|
|
1808
|
+
*/
|
|
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);
|
|
1817
|
+
}
|
|
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);
|
|
1831
|
+
}
|
|
1832
|
+
return roadmap;
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
|
|
948
1836
|
// src/server/services/templates.ts
|
|
949
1837
|
var taskTemplates = [
|
|
950
1838
|
{
|
|
@@ -1248,7 +2136,7 @@ function getTemplateById(id) {
|
|
|
1248
2136
|
}
|
|
1249
2137
|
|
|
1250
2138
|
// src/server/services/ai.ts
|
|
1251
|
-
import { spawn as
|
|
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 =
|
|
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
|
-
|
|
1641
|
-
|
|
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) => {
|