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 +1659 -235
- package/dist/bin/cli.js.map +1 -1
- package/dist/server/index.js +1653 -233
- package/dist/server/index.js.map +1 -1
- package/package.json +10 -2
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
|
|
7
|
+
import { existsSync as existsSync5 } from "fs";
|
|
8
8
|
import { execSync } from "child_process";
|
|
9
9
|
import { createInterface } from "readline";
|
|
10
|
-
import { join as
|
|
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
|
|
16
|
+
import { join as join6, dirname } from "path";
|
|
17
17
|
import { fileURLToPath } from "url";
|
|
18
|
-
import { existsSync as
|
|
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
|
-
|
|
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
|
|
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.
|
|
717
|
-
throw new Error("
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1195
|
+
this.runningTask = null;
|
|
1196
|
+
if (this.afkMode) {
|
|
1197
|
+
this.continueAFKMode();
|
|
1198
|
+
}
|
|
903
1199
|
}
|
|
904
1200
|
}
|
|
905
1201
|
/**
|
|
906
|
-
*
|
|
1202
|
+
* Build the QA verification prompt
|
|
907
1203
|
*/
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
*
|
|
1250
|
+
* Run QA verification for a task
|
|
947
1251
|
*/
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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.
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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 (
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
*
|
|
2075
|
+
* Run a Claude command and return the output
|
|
992
2076
|
*/
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
*
|
|
2135
|
+
* Mark a feature as added to kanban
|
|
1003
2136
|
*/
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
*
|
|
2149
|
+
* Delete a feature from the roadmap
|
|
1014
2150
|
*/
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
1680
|
-
if (
|
|
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
|
|
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:
|
|
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
|
|
2988
|
-
<h2 class="font-semibold text-canvas-900 text-
|
|
2989
|
-
<div class="flex items-center gap-2 mt-
|
|
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-
|
|
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-
|
|
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="
|
|
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
|
-
<!--
|
|
3013
|
-
<div class="
|
|
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
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
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
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
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
|
-
|
|
3107
|
-
<div>
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
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
|
-
|
|
3120
|
-
|
|
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
|
-
|
|
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
|
|
4889
|
+
return existsSync5(join7(dir, ".git"));
|
|
3466
4890
|
}
|
|
3467
4891
|
function hasGitRemote(dir) {
|
|
3468
4892
|
try {
|