ralphctl 0.1.0 → 0.1.2
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/README.md +58 -24
- package/dist/add-HGJCLWED.mjs +14 -0
- package/dist/add-MRGCS3US.mjs +14 -0
- package/dist/chunk-6PYTKGB5.mjs +316 -0
- package/dist/chunk-7TG3EAQ2.mjs +20 -0
- package/dist/chunk-EKMZZRWI.mjs +521 -0
- package/dist/chunk-JON4GCLR.mjs +59 -0
- package/dist/chunk-LOR7QBXX.mjs +3683 -0
- package/dist/chunk-MNMQC36F.mjs +556 -0
- package/dist/chunk-MRKOFVTM.mjs +537 -0
- package/dist/chunk-NTWO2LXB.mjs +52 -0
- package/dist/chunk-QBXHAXHI.mjs +562 -0
- package/dist/chunk-WGHJI3OI.mjs +214 -0
- package/dist/cli.mjs +4245 -0
- package/dist/create-MG7E7PLQ.mjs +10 -0
- package/dist/handle-UG5M2OON.mjs +22 -0
- package/dist/multiline-OHSNFCRG.mjs +40 -0
- package/dist/project-NT3L4FTB.mjs +28 -0
- package/dist/resolver-WSFWKACM.mjs +153 -0
- package/dist/sprint-4VHDLGFN.mjs +37 -0
- package/dist/wizard-LRELAN2J.mjs +196 -0
- package/package.json +19 -28
- package/CHANGELOG.md +0 -94
- package/bin/ralphctl +0 -13
- package/src/ai/executor.ts +0 -973
- package/src/ai/lifecycle.ts +0 -45
- package/src/ai/parser.ts +0 -40
- package/src/ai/permissions.ts +0 -207
- package/src/ai/process-manager.ts +0 -248
- package/src/ai/prompts/index.ts +0 -89
- package/src/ai/rate-limiter.ts +0 -89
- package/src/ai/runner.ts +0 -478
- package/src/ai/session.ts +0 -319
- package/src/ai/task-context.ts +0 -270
- package/src/cli-metadata.ts +0 -7
- package/src/cli.ts +0 -65
- package/src/commands/completion/index.ts +0 -33
- package/src/commands/config/config.ts +0 -58
- package/src/commands/config/index.ts +0 -33
- package/src/commands/dashboard/dashboard.ts +0 -5
- package/src/commands/dashboard/index.ts +0 -6
- package/src/commands/doctor/doctor.ts +0 -271
- package/src/commands/doctor/index.ts +0 -25
- package/src/commands/progress/index.ts +0 -25
- package/src/commands/progress/log.ts +0 -64
- package/src/commands/progress/show.ts +0 -14
- package/src/commands/project/add.ts +0 -336
- package/src/commands/project/index.ts +0 -104
- package/src/commands/project/list.ts +0 -31
- package/src/commands/project/remove.ts +0 -43
- package/src/commands/project/repo.ts +0 -118
- package/src/commands/project/show.ts +0 -49
- package/src/commands/sprint/close.ts +0 -180
- package/src/commands/sprint/context.ts +0 -109
- package/src/commands/sprint/create.ts +0 -60
- package/src/commands/sprint/current.ts +0 -75
- package/src/commands/sprint/delete.ts +0 -72
- package/src/commands/sprint/health.ts +0 -229
- package/src/commands/sprint/ideate.ts +0 -496
- package/src/commands/sprint/index.ts +0 -226
- package/src/commands/sprint/list.ts +0 -86
- package/src/commands/sprint/plan-utils.ts +0 -207
- package/src/commands/sprint/plan.ts +0 -549
- package/src/commands/sprint/refine.ts +0 -359
- package/src/commands/sprint/requirements.ts +0 -58
- package/src/commands/sprint/show.ts +0 -140
- package/src/commands/sprint/start.ts +0 -119
- package/src/commands/sprint/switch.ts +0 -20
- package/src/commands/task/add.ts +0 -316
- package/src/commands/task/import.ts +0 -150
- package/src/commands/task/index.ts +0 -123
- package/src/commands/task/list.ts +0 -145
- package/src/commands/task/next.ts +0 -45
- package/src/commands/task/remove.ts +0 -47
- package/src/commands/task/reorder.ts +0 -45
- package/src/commands/task/show.ts +0 -111
- package/src/commands/task/status.ts +0 -99
- package/src/commands/ticket/add.ts +0 -265
- package/src/commands/ticket/edit.ts +0 -166
- package/src/commands/ticket/index.ts +0 -114
- package/src/commands/ticket/list.ts +0 -128
- package/src/commands/ticket/refine-utils.ts +0 -89
- package/src/commands/ticket/refine.ts +0 -268
- package/src/commands/ticket/remove.ts +0 -48
- package/src/commands/ticket/show.ts +0 -74
- package/src/completion/handle.ts +0 -30
- package/src/completion/resolver.ts +0 -241
- package/src/interactive/dashboard.ts +0 -268
- package/src/interactive/escapable.ts +0 -81
- package/src/interactive/file-browser.ts +0 -153
- package/src/interactive/index.ts +0 -429
- package/src/interactive/menu.ts +0 -403
- package/src/interactive/selectors.ts +0 -273
- package/src/interactive/wizard.ts +0 -221
- package/src/providers/claude.ts +0 -53
- package/src/providers/copilot.ts +0 -86
- package/src/providers/index.ts +0 -43
- package/src/providers/types.ts +0 -85
- package/src/schemas/index.ts +0 -130
- package/src/store/config.ts +0 -74
- package/src/store/progress.ts +0 -230
- package/src/store/project.ts +0 -276
- package/src/store/sprint.ts +0 -229
- package/src/store/task.ts +0 -443
- package/src/store/ticket.ts +0 -178
- package/src/theme/index.ts +0 -215
- package/src/theme/ui.ts +0 -872
- package/src/utils/detect-scripts.ts +0 -247
- package/src/utils/editor-input.ts +0 -41
- package/src/utils/editor.ts +0 -37
- package/src/utils/exit-codes.ts +0 -27
- package/src/utils/file-lock.ts +0 -135
- package/src/utils/git.ts +0 -185
- package/src/utils/ids.ts +0 -37
- package/src/utils/issue-fetch.ts +0 -244
- package/src/utils/json-extract.ts +0 -62
- package/src/utils/multiline.ts +0 -61
- package/src/utils/path-selector.ts +0 -236
- package/src/utils/paths.ts +0 -108
- package/src/utils/provider.ts +0 -34
- package/src/utils/requirements-export.ts +0 -63
- package/src/utils/storage.ts +0 -107
- package/tsconfig.json +0 -25
- /package/{src/ai → dist}/prompts/ideate-auto.md +0 -0
- /package/{src/ai → dist}/prompts/ideate.md +0 -0
- /package/{src/ai → dist}/prompts/plan-auto.md +0 -0
- /package/{src/ai → dist}/prompts/plan-common.md +0 -0
- /package/{src/ai → dist}/prompts/plan-interactive.md +0 -0
- /package/{src/ai → dist}/prompts/task-execution.md +0 -0
- /package/{src/ai → dist}/prompts/ticket-refine.md +0 -0
|
@@ -0,0 +1,3683 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
escapableSelect
|
|
4
|
+
} from "./chunk-NTWO2LXB.mjs";
|
|
5
|
+
import {
|
|
6
|
+
IssueFetchError,
|
|
7
|
+
allRequirementsApproved,
|
|
8
|
+
fetchIssueFromUrl,
|
|
9
|
+
formatIssueContext,
|
|
10
|
+
formatTicketDisplay,
|
|
11
|
+
formatTicketId,
|
|
12
|
+
getPendingRequirements,
|
|
13
|
+
groupTicketsByProject,
|
|
14
|
+
listTickets
|
|
15
|
+
} from "./chunk-MNMQC36F.mjs";
|
|
16
|
+
import {
|
|
17
|
+
EXIT_ALL_BLOCKED,
|
|
18
|
+
EXIT_ERROR,
|
|
19
|
+
EXIT_INTERRUPTED,
|
|
20
|
+
EXIT_NO_TASKS,
|
|
21
|
+
EXIT_SUCCESS,
|
|
22
|
+
exitWithCode
|
|
23
|
+
} from "./chunk-7TG3EAQ2.mjs";
|
|
24
|
+
import {
|
|
25
|
+
ProjectNotFoundError,
|
|
26
|
+
getProject,
|
|
27
|
+
listProjects
|
|
28
|
+
} from "./chunk-WGHJI3OI.mjs";
|
|
29
|
+
import {
|
|
30
|
+
SprintNotFoundError,
|
|
31
|
+
SprintStatusError,
|
|
32
|
+
activateSprint,
|
|
33
|
+
assertSprintStatus,
|
|
34
|
+
closeSprint,
|
|
35
|
+
generateUuid8,
|
|
36
|
+
getAiProvider,
|
|
37
|
+
getProgress,
|
|
38
|
+
getSprint,
|
|
39
|
+
listSprints,
|
|
40
|
+
logProgress,
|
|
41
|
+
resolveSprintId,
|
|
42
|
+
saveSprint,
|
|
43
|
+
setAiProvider,
|
|
44
|
+
summarizeProgressForContext,
|
|
45
|
+
withFileLock
|
|
46
|
+
} from "./chunk-EKMZZRWI.mjs";
|
|
47
|
+
import {
|
|
48
|
+
ImportTasksSchema,
|
|
49
|
+
RefinedRequirementsSchema,
|
|
50
|
+
TasksSchema,
|
|
51
|
+
assertSafeCwd,
|
|
52
|
+
ensureDir,
|
|
53
|
+
fileExists,
|
|
54
|
+
getPlanningDir,
|
|
55
|
+
getProgressFilePath,
|
|
56
|
+
getRefinementDir,
|
|
57
|
+
getSchemaPath,
|
|
58
|
+
getSprintDir,
|
|
59
|
+
getTasksFilePath,
|
|
60
|
+
readValidatedJson,
|
|
61
|
+
writeValidatedJson
|
|
62
|
+
} from "./chunk-6PYTKGB5.mjs";
|
|
63
|
+
import {
|
|
64
|
+
colors,
|
|
65
|
+
createSpinner,
|
|
66
|
+
emoji,
|
|
67
|
+
error,
|
|
68
|
+
field,
|
|
69
|
+
fieldMultiline,
|
|
70
|
+
formatSprintStatus,
|
|
71
|
+
formatTaskStatus,
|
|
72
|
+
highlight,
|
|
73
|
+
icons,
|
|
74
|
+
info,
|
|
75
|
+
log,
|
|
76
|
+
muted,
|
|
77
|
+
printHeader,
|
|
78
|
+
printSeparator,
|
|
79
|
+
progressBar,
|
|
80
|
+
renderCard,
|
|
81
|
+
renderTable,
|
|
82
|
+
showError,
|
|
83
|
+
showInfo,
|
|
84
|
+
showNextStep,
|
|
85
|
+
showRandomQuote,
|
|
86
|
+
showSuccess,
|
|
87
|
+
showTip,
|
|
88
|
+
showWarning,
|
|
89
|
+
success,
|
|
90
|
+
terminalBell,
|
|
91
|
+
warning
|
|
92
|
+
} from "./chunk-QBXHAXHI.mjs";
|
|
93
|
+
|
|
94
|
+
// src/commands/sprint/refine.ts
|
|
95
|
+
import { mkdir, readFile } from "fs/promises";
|
|
96
|
+
import { join as join4 } from "path";
|
|
97
|
+
import { confirm } from "@inquirer/prompts";
|
|
98
|
+
|
|
99
|
+
// src/ai/prompts/index.ts
|
|
100
|
+
import { existsSync, readFileSync } from "fs";
|
|
101
|
+
import { dirname, join } from "path";
|
|
102
|
+
import { fileURLToPath } from "url";
|
|
103
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
104
|
+
function getPromptDir() {
|
|
105
|
+
const bundled = join(__dirname, "prompts");
|
|
106
|
+
if (existsSync(bundled)) return bundled;
|
|
107
|
+
return __dirname;
|
|
108
|
+
}
|
|
109
|
+
var promptDir = getPromptDir();
|
|
110
|
+
function loadTemplate(name) {
|
|
111
|
+
return readFileSync(join(promptDir, `${name}.md`), "utf-8");
|
|
112
|
+
}
|
|
113
|
+
function buildPlanPrompt(template, context, schema) {
|
|
114
|
+
const common = loadTemplate("plan-common");
|
|
115
|
+
return template.replace("{{COMMON}}", common).replace("{{CONTEXT}}", context).replace("{{SCHEMA}}", schema);
|
|
116
|
+
}
|
|
117
|
+
function buildInteractivePrompt(context, outputFile, schema) {
|
|
118
|
+
const template = loadTemplate("plan-interactive");
|
|
119
|
+
return buildPlanPrompt(template, context, schema).replace("{{OUTPUT_FILE}}", outputFile);
|
|
120
|
+
}
|
|
121
|
+
function buildAutoPrompt(context, schema) {
|
|
122
|
+
const template = loadTemplate("plan-auto");
|
|
123
|
+
return buildPlanPrompt(template, context, schema);
|
|
124
|
+
}
|
|
125
|
+
function buildTaskExecutionPrompt(progressFilePath, noCommit, contextFileName) {
|
|
126
|
+
const template = loadTemplate("task-execution");
|
|
127
|
+
const commitStep = noCommit ? "" : "\n> **Before continuing:** Create a git commit with a descriptive message for the changes made.\n";
|
|
128
|
+
const commitConstraint = noCommit ? "" : "- **Must commit** \u2014 Create a git commit before signaling completion.\n";
|
|
129
|
+
return template.replace("{{PROGRESS_FILE}}", progressFilePath).replace("{{COMMIT_STEP}}", commitStep).replace("{{COMMIT_CONSTRAINT}}", commitConstraint).replaceAll("{{CONTEXT_FILE}}", contextFileName);
|
|
130
|
+
}
|
|
131
|
+
function buildTicketRefinePrompt(ticketContent, outputFile, schema, issueContext = "") {
|
|
132
|
+
const template = loadTemplate("ticket-refine");
|
|
133
|
+
return template.replace("{{TICKET}}", ticketContent).replace("{{OUTPUT_FILE}}", outputFile).replace("{{SCHEMA}}", schema).replace("{{ISSUE_CONTEXT}}", issueContext);
|
|
134
|
+
}
|
|
135
|
+
function buildIdeatePrompt(ideaTitle, ideaDescription, projectName, repositories, outputFile, schema) {
|
|
136
|
+
const template = loadTemplate("ideate");
|
|
137
|
+
const common = loadTemplate("plan-common");
|
|
138
|
+
return template.replace("{{IDEA_TITLE}}", ideaTitle).replace("{{IDEA_DESCRIPTION}}", ideaDescription).replace("{{PROJECT_NAME}}", projectName).replace("{{REPOSITORIES}}", repositories).replace("{{OUTPUT_FILE}}", outputFile).replace("{{SCHEMA}}", schema).replace("{{COMMON}}", common);
|
|
139
|
+
}
|
|
140
|
+
function buildIdeateAutoPrompt(ideaTitle, ideaDescription, projectName, repositories, schema) {
|
|
141
|
+
const template = loadTemplate("ideate-auto");
|
|
142
|
+
const common = loadTemplate("plan-common");
|
|
143
|
+
return template.replace("{{IDEA_TITLE}}", ideaTitle).replace("{{IDEA_DESCRIPTION}}", ideaDescription).replace("{{PROJECT_NAME}}", projectName).replace("{{REPOSITORIES}}", repositories).replace("{{SCHEMA}}", schema).replace("{{COMMON}}", common);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/utils/requirements-export.ts
|
|
147
|
+
import { writeFile } from "fs/promises";
|
|
148
|
+
import { dirname as dirname2 } from "path";
|
|
149
|
+
function formatRequirementsMarkdown(sprint) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
lines.push(`# Sprint Requirements: ${sprint.name}`);
|
|
152
|
+
lines.push("");
|
|
153
|
+
lines.push(`Sprint ID: ${sprint.id}`);
|
|
154
|
+
lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
155
|
+
lines.push(`Status: ${sprint.status}`);
|
|
156
|
+
if (sprint.tickets.length === 0) {
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push("---");
|
|
159
|
+
lines.push("");
|
|
160
|
+
lines.push("_No tickets in this sprint._");
|
|
161
|
+
return lines.join("\n") + "\n";
|
|
162
|
+
}
|
|
163
|
+
for (const ticket of sprint.tickets) {
|
|
164
|
+
lines.push("");
|
|
165
|
+
lines.push("---");
|
|
166
|
+
lines.push("");
|
|
167
|
+
lines.push(formatTicketSection(ticket));
|
|
168
|
+
}
|
|
169
|
+
return lines.join("\n") + "\n";
|
|
170
|
+
}
|
|
171
|
+
function formatTicketSection(ticket) {
|
|
172
|
+
const lines = [];
|
|
173
|
+
lines.push(`## ${ticket.projectName} - ${ticket.title}`);
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push(`**Ticket ID:** ${ticket.id}`);
|
|
176
|
+
lines.push(`**Status:** ${ticket.requirementStatus}`);
|
|
177
|
+
if (ticket.link) {
|
|
178
|
+
lines.push(`**Link:** ${ticket.link}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push("### Requirements");
|
|
182
|
+
lines.push("");
|
|
183
|
+
lines.push(ticket.requirements ?? "_No requirements defined_");
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
|
186
|
+
async function exportRequirementsToMarkdown(sprint, outputPath) {
|
|
187
|
+
const content = formatRequirementsMarkdown(sprint);
|
|
188
|
+
await ensureDir(dirname2(outputPath));
|
|
189
|
+
await writeFile(outputPath, content, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/utils/provider.ts
|
|
193
|
+
import { select } from "@inquirer/prompts";
|
|
194
|
+
async function resolveProvider() {
|
|
195
|
+
const stored = await getAiProvider();
|
|
196
|
+
if (stored) return stored;
|
|
197
|
+
const choice = await select({
|
|
198
|
+
message: `${emoji.donut} Which AI buddy should help with my homework?`,
|
|
199
|
+
choices: [
|
|
200
|
+
{ name: "Claude Code", value: "claude" },
|
|
201
|
+
{ name: "GitHub Copilot", value: "copilot" }
|
|
202
|
+
]
|
|
203
|
+
});
|
|
204
|
+
await setAiProvider(choice);
|
|
205
|
+
return choice;
|
|
206
|
+
}
|
|
207
|
+
function providerDisplayName(provider) {
|
|
208
|
+
return provider === "claude" ? "Claude" : "Copilot";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/commands/ticket/refine-utils.ts
|
|
212
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
213
|
+
import { join as join3 } from "path";
|
|
214
|
+
|
|
215
|
+
// src/ai/session.ts
|
|
216
|
+
import { spawn, spawnSync } from "child_process";
|
|
217
|
+
|
|
218
|
+
// src/ai/process-manager.ts
|
|
219
|
+
var GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
220
|
+
var FORCE_QUIT_WINDOW_MS = 5e3;
|
|
221
|
+
var ProcessManager = class _ProcessManager {
|
|
222
|
+
static instance = null;
|
|
223
|
+
/** All active AI child processes */
|
|
224
|
+
children = /* @__PURE__ */ new Set();
|
|
225
|
+
/** Cleanup callbacks (for stopping spinners, removing temp files) */
|
|
226
|
+
cleanupCallbacks = /* @__PURE__ */ new Set();
|
|
227
|
+
/** Whether we're currently shutting down */
|
|
228
|
+
exiting = false;
|
|
229
|
+
/** Whether signal handlers have been installed */
|
|
230
|
+
handlersInstalled = false;
|
|
231
|
+
/** Timestamp of first SIGINT (for double-signal detection) */
|
|
232
|
+
firstSigintAt = null;
|
|
233
|
+
constructor() {
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Get the singleton instance.
|
|
237
|
+
*/
|
|
238
|
+
static getInstance() {
|
|
239
|
+
_ProcessManager.instance ??= new _ProcessManager();
|
|
240
|
+
return _ProcessManager.instance;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Reset the singleton for testing.
|
|
244
|
+
* @internal
|
|
245
|
+
*/
|
|
246
|
+
static resetForTesting() {
|
|
247
|
+
if (_ProcessManager.instance) {
|
|
248
|
+
_ProcessManager.instance.dispose();
|
|
249
|
+
_ProcessManager.instance = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Register a child process for tracking.
|
|
254
|
+
* Automatically installs signal handlers on first registration.
|
|
255
|
+
* Throws an error if called during shutdown.
|
|
256
|
+
*
|
|
257
|
+
* @throws Error if called during shutdown
|
|
258
|
+
*/
|
|
259
|
+
registerChild(child) {
|
|
260
|
+
if (this.exiting) {
|
|
261
|
+
throw new Error("Cannot register child process during shutdown");
|
|
262
|
+
}
|
|
263
|
+
this.children.add(child);
|
|
264
|
+
child.once("close", () => {
|
|
265
|
+
this.children.delete(child);
|
|
266
|
+
});
|
|
267
|
+
if (!this.handlersInstalled) {
|
|
268
|
+
this.installSignalHandlers();
|
|
269
|
+
this.handlersInstalled = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Eagerly install signal handlers without requiring a child registration.
|
|
274
|
+
* Call this at the top of execution loops so Ctrl+C works even before
|
|
275
|
+
* the first AI process is spawned (e.g. while the spinner is visible).
|
|
276
|
+
* Idempotent — safe to call multiple times.
|
|
277
|
+
*/
|
|
278
|
+
ensureHandlers() {
|
|
279
|
+
if (!this.handlersInstalled) {
|
|
280
|
+
this.installSignalHandlers();
|
|
281
|
+
this.handlersInstalled = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Check if a shutdown is in progress.
|
|
286
|
+
* Used by execution loops to break immediately on Ctrl+C.
|
|
287
|
+
*/
|
|
288
|
+
isShuttingDown() {
|
|
289
|
+
return this.exiting;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Manually unregister a child process.
|
|
293
|
+
* Normally not needed - children auto-unregister via event listeners.
|
|
294
|
+
*/
|
|
295
|
+
unregisterChild(child) {
|
|
296
|
+
this.children.delete(child);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Register a cleanup callback (for spinners, temp files, etc.).
|
|
300
|
+
* Returns a deregister function.
|
|
301
|
+
*/
|
|
302
|
+
registerCleanup(callback) {
|
|
303
|
+
this.cleanupCallbacks.add(callback);
|
|
304
|
+
return () => {
|
|
305
|
+
this.cleanupCallbacks.delete(callback);
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Kill all tracked child processes with the given signal.
|
|
310
|
+
* Catches errors (ESRCH = already dead, EPERM = permission denied).
|
|
311
|
+
*/
|
|
312
|
+
killAll(signal) {
|
|
313
|
+
for (const child of this.children) {
|
|
314
|
+
try {
|
|
315
|
+
child.kill(signal);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const error2 = err;
|
|
318
|
+
if (error2.code === "ESRCH") {
|
|
319
|
+
this.children.delete(child);
|
|
320
|
+
} else if (error2.code === "EPERM") {
|
|
321
|
+
console.warn(`Warning: Permission denied killing process ${String(child.pid)}`);
|
|
322
|
+
} else {
|
|
323
|
+
console.error(`Error killing process ${String(child.pid)}:`, error2.message);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Graceful shutdown sequence:
|
|
330
|
+
* 1. Run all cleanup callbacks (stop spinners)
|
|
331
|
+
* 2. Send SIGINT to all children (what AI CLI processes expect)
|
|
332
|
+
* 3. Wait up to 5 seconds for children to exit
|
|
333
|
+
* 4. Send SIGKILL to any remaining children (force)
|
|
334
|
+
* 5. Exit with code 130 (SIGINT) or 1 (force-quit)
|
|
335
|
+
*
|
|
336
|
+
* Double Ctrl+C: immediate SIGKILL + exit(1)
|
|
337
|
+
*/
|
|
338
|
+
async shutdown(signal) {
|
|
339
|
+
if (signal === "SIGINT" && this.firstSigintAt) {
|
|
340
|
+
const now = Date.now();
|
|
341
|
+
if (now - this.firstSigintAt < FORCE_QUIT_WINDOW_MS) {
|
|
342
|
+
console.log("\n\nForce quit (double signal) \u2014 killing all processes immediately...");
|
|
343
|
+
this.killAll("SIGKILL");
|
|
344
|
+
process.exit(1);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (this.exiting) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.exiting = true;
|
|
352
|
+
if (signal === "SIGINT") {
|
|
353
|
+
this.firstSigintAt = Date.now();
|
|
354
|
+
}
|
|
355
|
+
console.log("\n\nShutting down gracefully... (press Ctrl+C again to force-quit)");
|
|
356
|
+
for (const callback of this.cleanupCallbacks) {
|
|
357
|
+
try {
|
|
358
|
+
callback();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const error2 = err;
|
|
361
|
+
console.error("Error in cleanup callback:", error2.message);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.cleanupCallbacks.clear();
|
|
365
|
+
this.killAll("SIGINT");
|
|
366
|
+
const waitStart = Date.now();
|
|
367
|
+
while (this.children.size > 0 && Date.now() - waitStart < GRACEFUL_SHUTDOWN_TIMEOUT_MS) {
|
|
368
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
369
|
+
}
|
|
370
|
+
if (this.children.size > 0) {
|
|
371
|
+
console.log(`Force-killing ${String(this.children.size)} remaining process(es)...`);
|
|
372
|
+
this.killAll("SIGKILL");
|
|
373
|
+
}
|
|
374
|
+
process.exit(signal === "SIGINT" ? EXIT_INTERRUPTED : 1);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Clean up all resources (for testing).
|
|
378
|
+
* @internal
|
|
379
|
+
*/
|
|
380
|
+
dispose() {
|
|
381
|
+
this.children.clear();
|
|
382
|
+
this.cleanupCallbacks.clear();
|
|
383
|
+
this.exiting = false;
|
|
384
|
+
this.handlersInstalled = false;
|
|
385
|
+
this.firstSigintAt = null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Install signal handlers for SIGINT and SIGTERM.
|
|
389
|
+
* Uses process.on() (persistent) not process.once() (one-shot).
|
|
390
|
+
*/
|
|
391
|
+
installSignalHandlers() {
|
|
392
|
+
process.on("SIGINT", () => {
|
|
393
|
+
void this.shutdown("SIGINT");
|
|
394
|
+
});
|
|
395
|
+
process.on("SIGTERM", () => {
|
|
396
|
+
void this.shutdown("SIGTERM");
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// src/providers/claude.ts
|
|
402
|
+
var claudeAdapter = {
|
|
403
|
+
name: "claude",
|
|
404
|
+
displayName: "Claude",
|
|
405
|
+
binary: "claude",
|
|
406
|
+
baseArgs: ["--permission-mode", "acceptEdits"],
|
|
407
|
+
experimental: false,
|
|
408
|
+
buildInteractiveArgs(prompt, extraArgs = []) {
|
|
409
|
+
return [...this.baseArgs, ...extraArgs, "--", prompt];
|
|
410
|
+
},
|
|
411
|
+
buildHeadlessArgs(extraArgs = []) {
|
|
412
|
+
return ["-p", "--output-format", "json", ...this.baseArgs, ...extraArgs];
|
|
413
|
+
},
|
|
414
|
+
parseJsonOutput(stdout) {
|
|
415
|
+
try {
|
|
416
|
+
const parsed = JSON.parse(stdout);
|
|
417
|
+
return {
|
|
418
|
+
result: parsed.result ?? stdout,
|
|
419
|
+
sessionId: parsed.session_id ?? null
|
|
420
|
+
};
|
|
421
|
+
} catch {
|
|
422
|
+
return { result: stdout, sessionId: null };
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
detectRateLimit(stderr) {
|
|
426
|
+
const patterns = [/rate.?limit/i, /\b429\b/, /too many requests/i, /overloaded/i, /\b529\b/];
|
|
427
|
+
const isRateLimited = patterns.some((p) => p.test(stderr));
|
|
428
|
+
if (!isRateLimited) {
|
|
429
|
+
return { rateLimited: false, retryAfterMs: null };
|
|
430
|
+
}
|
|
431
|
+
const retryMatch = /retry.?after:?\s*(\d+)/i.exec(stderr);
|
|
432
|
+
const retryAfterMs = retryMatch?.[1] ? parseInt(retryMatch[1], 10) * 1e3 : null;
|
|
433
|
+
return { rateLimited: true, retryAfterMs };
|
|
434
|
+
},
|
|
435
|
+
getSpawnEnv() {
|
|
436
|
+
return { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" };
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// src/providers/copilot.ts
|
|
441
|
+
import { lstat, readdir, unlink } from "fs/promises";
|
|
442
|
+
import { join as join2 } from "path";
|
|
443
|
+
var copilotAdapter = {
|
|
444
|
+
name: "copilot",
|
|
445
|
+
displayName: "Copilot",
|
|
446
|
+
binary: "copilot",
|
|
447
|
+
experimental: true,
|
|
448
|
+
baseArgs: ["--allow-all-tools"],
|
|
449
|
+
buildInteractiveArgs(prompt, extraArgs = []) {
|
|
450
|
+
return [...this.baseArgs, ...extraArgs, "-i", prompt];
|
|
451
|
+
},
|
|
452
|
+
buildHeadlessArgs(extraArgs = []) {
|
|
453
|
+
return ["-p", "-s", "--autopilot", "--no-ask-user", "--share", ...this.baseArgs, ...extraArgs];
|
|
454
|
+
},
|
|
455
|
+
parseJsonOutput(stdout) {
|
|
456
|
+
return { result: stdout.trim(), sessionId: null };
|
|
457
|
+
},
|
|
458
|
+
async extractSessionId(cwd) {
|
|
459
|
+
try {
|
|
460
|
+
const files = await readdir(cwd);
|
|
461
|
+
const shareFile = files.find((f) => /^copilot-session-[a-zA-Z0-9_][a-zA-Z0-9_-]*\.md$/.test(f));
|
|
462
|
+
if (!shareFile) return null;
|
|
463
|
+
const match = /^copilot-session-([a-zA-Z0-9_][a-zA-Z0-9_-]{0,127})\.md$/.exec(shareFile);
|
|
464
|
+
if (!match?.[1]) return null;
|
|
465
|
+
const filePath = join2(cwd, shareFile);
|
|
466
|
+
const stat = await lstat(filePath).catch(() => null);
|
|
467
|
+
if (stat?.isFile()) {
|
|
468
|
+
await unlink(filePath).catch(() => {
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return match[1];
|
|
472
|
+
} catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
detectRateLimit(stderr) {
|
|
477
|
+
const patterns = [/rate.?limit/i, /\b429\b/, /too many requests/i, /overloaded/i, /\b529\b/];
|
|
478
|
+
const isRateLimited = patterns.some((p) => p.test(stderr));
|
|
479
|
+
if (!isRateLimited) {
|
|
480
|
+
return { rateLimited: false, retryAfterMs: null };
|
|
481
|
+
}
|
|
482
|
+
const retryMatch = /retry.?after:?\s*(\d+)/i.exec(stderr);
|
|
483
|
+
const retryAfterMs = retryMatch?.[1] ? parseInt(retryMatch[1], 10) * 1e3 : null;
|
|
484
|
+
return { rateLimited: true, retryAfterMs };
|
|
485
|
+
},
|
|
486
|
+
getSpawnEnv() {
|
|
487
|
+
return {};
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// src/providers/index.ts
|
|
492
|
+
function getProvider(provider) {
|
|
493
|
+
switch (provider) {
|
|
494
|
+
case "claude":
|
|
495
|
+
return claudeAdapter;
|
|
496
|
+
case "copilot":
|
|
497
|
+
return copilotAdapter;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function getActiveProvider() {
|
|
501
|
+
const provider = await resolveProvider();
|
|
502
|
+
const adapter = getProvider(provider);
|
|
503
|
+
if (adapter.experimental) {
|
|
504
|
+
showWarning(`${adapter.displayName} provider is in public preview \u2014 some features may not work as expected.`);
|
|
505
|
+
}
|
|
506
|
+
return adapter;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/ai/session.ts
|
|
510
|
+
var SpawnError = class extends Error {
|
|
511
|
+
stderr;
|
|
512
|
+
exitCode;
|
|
513
|
+
rateLimited;
|
|
514
|
+
retryAfterMs;
|
|
515
|
+
/** Session ID if available (for resume after rate limit) */
|
|
516
|
+
sessionId;
|
|
517
|
+
constructor(message, stderr, exitCode, sessionId, provider) {
|
|
518
|
+
super(message);
|
|
519
|
+
this.name = "SpawnError";
|
|
520
|
+
this.stderr = stderr;
|
|
521
|
+
this.exitCode = exitCode;
|
|
522
|
+
this.sessionId = sessionId ?? null;
|
|
523
|
+
const rl = provider ? provider.detectRateLimit(stderr) : detectRateLimitFallback(stderr);
|
|
524
|
+
this.rateLimited = rl.rateLimited;
|
|
525
|
+
this.retryAfterMs = rl.retryAfterMs;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
function detectRateLimitFallback(stderr) {
|
|
529
|
+
const patterns = [/rate.?limit/i, /\b429\b/, /too many requests/i, /overloaded/i, /\b529\b/];
|
|
530
|
+
const isRateLimited = patterns.some((p) => p.test(stderr));
|
|
531
|
+
if (!isRateLimited) {
|
|
532
|
+
return { rateLimited: false, retryAfterMs: null };
|
|
533
|
+
}
|
|
534
|
+
const retryMatch = /retry.?after:?\s*(\d+)/i.exec(stderr);
|
|
535
|
+
const retryAfterMs = retryMatch?.[1] ? parseInt(retryMatch[1], 10) * 1e3 : null;
|
|
536
|
+
return { rateLimited: true, retryAfterMs };
|
|
537
|
+
}
|
|
538
|
+
function spawnInteractive(prompt, options, provider) {
|
|
539
|
+
assertSafeCwd(options.cwd);
|
|
540
|
+
const p = provider ?? {
|
|
541
|
+
binary: "claude",
|
|
542
|
+
baseArgs: ["--permission-mode", "acceptEdits"],
|
|
543
|
+
buildInteractiveArgs: (pr, extra = []) => [
|
|
544
|
+
...["--permission-mode", "acceptEdits"],
|
|
545
|
+
...extra,
|
|
546
|
+
"--",
|
|
547
|
+
pr
|
|
548
|
+
]
|
|
549
|
+
};
|
|
550
|
+
const args = prompt ? p.buildInteractiveArgs(prompt, options.args ?? []) : [...p.baseArgs, ...options.args ?? []];
|
|
551
|
+
const env = options.env ? { ...process.env, ...options.env } : void 0;
|
|
552
|
+
const result = spawnSync(p.binary, args, {
|
|
553
|
+
cwd: options.cwd,
|
|
554
|
+
stdio: "inherit",
|
|
555
|
+
env
|
|
556
|
+
});
|
|
557
|
+
if (result.error) {
|
|
558
|
+
return { code: 1, error: `Failed to spawn ${p.binary} CLI: ${result.error.message}` };
|
|
559
|
+
}
|
|
560
|
+
return { code: result.status ?? 1 };
|
|
561
|
+
}
|
|
562
|
+
async function spawnHeadless(options, provider) {
|
|
563
|
+
const result = await spawnHeadlessRaw(options, provider);
|
|
564
|
+
return result.stdout;
|
|
565
|
+
}
|
|
566
|
+
async function spawnHeadlessRaw(options, provider) {
|
|
567
|
+
assertSafeCwd(options.cwd);
|
|
568
|
+
const p = provider ?? await getActiveProvider();
|
|
569
|
+
return new Promise((resolve, reject) => {
|
|
570
|
+
const allArgs = p.buildHeadlessArgs(options.args ?? []);
|
|
571
|
+
if (options.resumeSessionId) {
|
|
572
|
+
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,127}$/.test(options.resumeSessionId)) {
|
|
573
|
+
reject(new SpawnError("Invalid session ID format", "", 1, null, p));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
allArgs.push("--resume", options.resumeSessionId);
|
|
577
|
+
}
|
|
578
|
+
const child = spawn(p.binary, allArgs, {
|
|
579
|
+
cwd: options.cwd,
|
|
580
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
581
|
+
env: options.env ? { ...process.env, ...options.env } : void 0
|
|
582
|
+
});
|
|
583
|
+
const manager = ProcessManager.getInstance();
|
|
584
|
+
try {
|
|
585
|
+
manager.registerChild(child);
|
|
586
|
+
} catch {
|
|
587
|
+
reject(new SpawnError("Cannot spawn during shutdown", "", 1, null, p));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const MAX_PROMPT_SIZE = 1e6;
|
|
591
|
+
if (options.prompt) {
|
|
592
|
+
if (options.prompt.length > MAX_PROMPT_SIZE) {
|
|
593
|
+
reject(new SpawnError("Prompt exceeds maximum size (1MB)", "", 1, null, p));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
child.stdin.write(options.prompt);
|
|
597
|
+
}
|
|
598
|
+
child.stdin.end();
|
|
599
|
+
let rawStdout = "";
|
|
600
|
+
let stderr = "";
|
|
601
|
+
child.stdout.on("data", (data) => {
|
|
602
|
+
rawStdout += data.toString();
|
|
603
|
+
});
|
|
604
|
+
child.stderr.on("data", (data) => {
|
|
605
|
+
stderr += data.toString();
|
|
606
|
+
});
|
|
607
|
+
child.on("close", (code) => {
|
|
608
|
+
void (async () => {
|
|
609
|
+
const exitCode = code ?? 1;
|
|
610
|
+
const { result, sessionId: parsedSessionId } = p.parseJsonOutput(rawStdout);
|
|
611
|
+
const sessionId = parsedSessionId ?? await p.extractSessionId?.(options.cwd) ?? null;
|
|
612
|
+
if (exitCode !== 0) {
|
|
613
|
+
reject(
|
|
614
|
+
new SpawnError(
|
|
615
|
+
`${p.displayName} CLI exited with code ${String(exitCode)}: ${stderr}`,
|
|
616
|
+
stderr,
|
|
617
|
+
exitCode,
|
|
618
|
+
sessionId,
|
|
619
|
+
p
|
|
620
|
+
)
|
|
621
|
+
);
|
|
622
|
+
} else {
|
|
623
|
+
resolve({ stdout: result, stderr, exitCode: 0, sessionId });
|
|
624
|
+
}
|
|
625
|
+
})().catch((err) => {
|
|
626
|
+
reject(new SpawnError(`Unexpected error in close handler: ${String(err)}`, "", 1, null, p));
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
child.on("error", (err) => {
|
|
630
|
+
reject(new SpawnError(`Failed to spawn ${p.binary} CLI: ${err.message}`, "", 1, null, p));
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
var DEFAULT_MAX_RETRIES = 5;
|
|
635
|
+
var BASE_DELAY_MS = 2e3;
|
|
636
|
+
var MAX_DELAY_MS = 12e4;
|
|
637
|
+
var DEFAULT_TOTAL_TIMEOUT_MS = 6e5;
|
|
638
|
+
function sleep(ms) {
|
|
639
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
640
|
+
}
|
|
641
|
+
function jitter() {
|
|
642
|
+
return Math.floor(Math.random() * 1e3);
|
|
643
|
+
}
|
|
644
|
+
async function spawnWithRetry(options, retryOptions, provider) {
|
|
645
|
+
const p = provider ?? await getActiveProvider();
|
|
646
|
+
const maxRetries = retryOptions?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
647
|
+
const totalTimeoutMs = retryOptions?.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
|
|
648
|
+
const startTime = Date.now();
|
|
649
|
+
let resumeSessionId = options.resumeSessionId;
|
|
650
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
651
|
+
const elapsed = Date.now() - startTime;
|
|
652
|
+
if (attempt > 0 && elapsed >= totalTimeoutMs) {
|
|
653
|
+
throw new SpawnError(`Total retry timeout exceeded (${String(totalTimeoutMs)}ms)`, "", 1, resumeSessionId, p);
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
return await spawnHeadlessRaw({ ...options, resumeSessionId }, p);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
if (!(err instanceof SpawnError) || !err.rateLimited) {
|
|
659
|
+
throw err;
|
|
660
|
+
}
|
|
661
|
+
if (err.sessionId) {
|
|
662
|
+
resumeSessionId = err.sessionId;
|
|
663
|
+
}
|
|
664
|
+
if (attempt >= maxRetries) {
|
|
665
|
+
throw err;
|
|
666
|
+
}
|
|
667
|
+
const delay = Math.min(err.retryAfterMs ?? BASE_DELAY_MS * Math.pow(2, attempt), MAX_DELAY_MS) + jitter();
|
|
668
|
+
retryOptions?.onRetry?.(attempt + 1, delay, err);
|
|
669
|
+
await sleep(delay);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
throw new Error("Max retries exceeded");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/utils/json-extract.ts
|
|
676
|
+
function extractJsonStructure(output, open, close, typeName) {
|
|
677
|
+
const start = output.indexOf(open);
|
|
678
|
+
if (start === -1) {
|
|
679
|
+
throw new Error(`No JSON ${typeName} found in output`);
|
|
680
|
+
}
|
|
681
|
+
let depth = 0;
|
|
682
|
+
let inString = false;
|
|
683
|
+
let escape = false;
|
|
684
|
+
for (let i = start; i < output.length; i++) {
|
|
685
|
+
const ch = output[i];
|
|
686
|
+
if (escape) {
|
|
687
|
+
escape = false;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (ch === "\\" && inString) {
|
|
691
|
+
escape = true;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (ch === '"') {
|
|
695
|
+
inString = !inString;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (inString) continue;
|
|
699
|
+
if (ch === open) depth++;
|
|
700
|
+
if (ch === close) {
|
|
701
|
+
depth--;
|
|
702
|
+
if (depth === 0) {
|
|
703
|
+
return output.slice(start, i + 1);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
throw new Error(`No complete JSON ${typeName} found in output`);
|
|
708
|
+
}
|
|
709
|
+
function extractJsonArray(output) {
|
|
710
|
+
return extractJsonStructure(output, "[", "]", "array");
|
|
711
|
+
}
|
|
712
|
+
function extractJsonObject(output) {
|
|
713
|
+
return extractJsonStructure(output, "{", "}", "object");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/commands/ticket/refine-utils.ts
|
|
717
|
+
function formatTicketForPrompt(ticket) {
|
|
718
|
+
const lines = [];
|
|
719
|
+
lines.push(`### ${formatTicketDisplay(ticket)}`);
|
|
720
|
+
lines.push(`Project: ${ticket.projectName}`);
|
|
721
|
+
if (ticket.description) {
|
|
722
|
+
lines.push("");
|
|
723
|
+
lines.push("**Description:**");
|
|
724
|
+
lines.push(ticket.description);
|
|
725
|
+
}
|
|
726
|
+
if (ticket.link) {
|
|
727
|
+
lines.push("");
|
|
728
|
+
lines.push(`**Link:** ${ticket.link}`);
|
|
729
|
+
}
|
|
730
|
+
lines.push("");
|
|
731
|
+
return lines.join("\n");
|
|
732
|
+
}
|
|
733
|
+
function parseRequirementsFile(content) {
|
|
734
|
+
const jsonStr = extractJsonArray(content);
|
|
735
|
+
let parsed;
|
|
736
|
+
try {
|
|
737
|
+
parsed = JSON.parse(jsonStr);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
throw new Error(`Invalid JSON: ${err instanceof Error ? err.message : "parse error"}`, { cause: err });
|
|
740
|
+
}
|
|
741
|
+
if (!Array.isArray(parsed)) {
|
|
742
|
+
throw new Error("Expected JSON array");
|
|
743
|
+
}
|
|
744
|
+
const result = RefinedRequirementsSchema.safeParse(parsed);
|
|
745
|
+
if (!result.success) {
|
|
746
|
+
const issues = result.error.issues.map((issue) => {
|
|
747
|
+
const path = issue.path.length > 0 ? `[${issue.path.join(".")}]` : "";
|
|
748
|
+
return ` ${path}: ${issue.message}`;
|
|
749
|
+
}).join("\n");
|
|
750
|
+
throw new Error(`Invalid requirements format:
|
|
751
|
+
${issues}`);
|
|
752
|
+
}
|
|
753
|
+
return result.data;
|
|
754
|
+
}
|
|
755
|
+
async function runAiSession(workingDir, prompt, ticketTitle) {
|
|
756
|
+
const contextFile = join3(workingDir, "refine-context.md");
|
|
757
|
+
await writeFile2(contextFile, prompt, "utf-8");
|
|
758
|
+
const provider = await getActiveProvider();
|
|
759
|
+
const startPrompt = `I need help refining the requirements for "${ticketTitle}". The full context is in refine-context.md. Please read that file now and follow the instructions to help refine the ticket requirements.`;
|
|
760
|
+
const result = spawnInteractive(
|
|
761
|
+
startPrompt,
|
|
762
|
+
{
|
|
763
|
+
cwd: workingDir
|
|
764
|
+
},
|
|
765
|
+
provider
|
|
766
|
+
);
|
|
767
|
+
if (result.error) {
|
|
768
|
+
throw new Error(result.error);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/commands/sprint/refine.ts
|
|
773
|
+
function parseArgs(args) {
|
|
774
|
+
const options = {};
|
|
775
|
+
let sprintId;
|
|
776
|
+
for (let i = 0; i < args.length; i++) {
|
|
777
|
+
const arg = args[i];
|
|
778
|
+
const nextArg = args[i + 1];
|
|
779
|
+
if (arg === "--project") {
|
|
780
|
+
options.project = nextArg;
|
|
781
|
+
i++;
|
|
782
|
+
} else if (!arg?.startsWith("-")) {
|
|
783
|
+
sprintId = arg;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return { sprintId, options };
|
|
787
|
+
}
|
|
788
|
+
async function sprintRefineCommand(args) {
|
|
789
|
+
const { sprintId, options } = parseArgs(args);
|
|
790
|
+
let id;
|
|
791
|
+
try {
|
|
792
|
+
id = await resolveSprintId(sprintId);
|
|
793
|
+
} catch {
|
|
794
|
+
showWarning("No sprint specified and no current sprint set.");
|
|
795
|
+
showTip("Specify a sprint ID or create one first.");
|
|
796
|
+
log.newline();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const sprint = await getSprint(id);
|
|
800
|
+
try {
|
|
801
|
+
assertSprintStatus(sprint, ["draft"], "refine");
|
|
802
|
+
} catch (err) {
|
|
803
|
+
if (err instanceof Error) {
|
|
804
|
+
showError(err.message);
|
|
805
|
+
log.newline();
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (sprint.tickets.length === 0) {
|
|
810
|
+
showWarning("No tickets in sprint.");
|
|
811
|
+
showTip("Add tickets first: ralphctl ticket add --project <project-name>");
|
|
812
|
+
log.newline();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
let pendingTickets = getPendingRequirements(sprint.tickets);
|
|
816
|
+
if (options.project) {
|
|
817
|
+
pendingTickets = pendingTickets.filter((t) => t.projectName === options.project);
|
|
818
|
+
if (pendingTickets.length === 0) {
|
|
819
|
+
showWarning(`No pending tickets for project: ${options.project}`);
|
|
820
|
+
log.newline();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (pendingTickets.length === 0) {
|
|
825
|
+
showSuccess("All tickets already have approved requirements!");
|
|
826
|
+
showTip('Run "ralphctl sprint plan" to generate tasks.');
|
|
827
|
+
log.newline();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
printHeader("Requirements Refinement", icons.ticket);
|
|
831
|
+
console.log(field("Sprint", sprint.name));
|
|
832
|
+
console.log(field("ID", sprint.id));
|
|
833
|
+
console.log(field("Pending", `${String(pendingTickets.length)} ticket(s)`));
|
|
834
|
+
log.newline();
|
|
835
|
+
const schemaPath = getSchemaPath("requirements-output.schema.json");
|
|
836
|
+
const schema = await readFile(schemaPath, "utf-8");
|
|
837
|
+
const providerName = providerDisplayName(await resolveProvider());
|
|
838
|
+
let approved = 0;
|
|
839
|
+
let skipped = 0;
|
|
840
|
+
for (let i = 0; i < pendingTickets.length; i++) {
|
|
841
|
+
const ticket = pendingTickets[i];
|
|
842
|
+
if (!ticket) continue;
|
|
843
|
+
const ticketNum = i + 1;
|
|
844
|
+
const totalTickets = pendingTickets.length;
|
|
845
|
+
printSeparator(60);
|
|
846
|
+
console.log("");
|
|
847
|
+
console.log(` ${icons.ticket} ${info(`Ticket ${String(ticketNum)} of ${String(totalTickets)}`)}`);
|
|
848
|
+
console.log(
|
|
849
|
+
` ${progressBar(i, totalTickets, {
|
|
850
|
+
width: 15,
|
|
851
|
+
showPercent: false
|
|
852
|
+
})} ${colors.muted(`${String(ticketNum)}/${String(totalTickets)}`)}`
|
|
853
|
+
);
|
|
854
|
+
console.log("");
|
|
855
|
+
console.log(field("Title", ticket.title, 14));
|
|
856
|
+
console.log(field("Project", ticket.projectName, 14));
|
|
857
|
+
if (ticket.link) {
|
|
858
|
+
console.log(field("Link", ticket.link, 14));
|
|
859
|
+
}
|
|
860
|
+
if (ticket.description) {
|
|
861
|
+
console.log(fieldMultiline("Description", ticket.description, 14));
|
|
862
|
+
}
|
|
863
|
+
log.newline();
|
|
864
|
+
try {
|
|
865
|
+
await getProject(ticket.projectName);
|
|
866
|
+
} catch {
|
|
867
|
+
showWarning(`Project '${ticket.projectName}' not found.`);
|
|
868
|
+
log.dim("Skipping this ticket.");
|
|
869
|
+
log.newline();
|
|
870
|
+
skipped++;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const proceed = await confirm({
|
|
874
|
+
message: `${emoji.donut} Start ${providerName} refinement session for this ticket?`,
|
|
875
|
+
default: true
|
|
876
|
+
});
|
|
877
|
+
if (!proceed) {
|
|
878
|
+
log.dim("Skipped. You can refine this ticket later.");
|
|
879
|
+
log.newline();
|
|
880
|
+
skipped++;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
let issueContext = "";
|
|
884
|
+
if (ticket.link) {
|
|
885
|
+
const fetchSpinner = createSpinner("Fetching issue data...");
|
|
886
|
+
fetchSpinner.start();
|
|
887
|
+
try {
|
|
888
|
+
const issueData = fetchIssueFromUrl(ticket.link);
|
|
889
|
+
if (issueData) {
|
|
890
|
+
issueContext = formatIssueContext(issueData);
|
|
891
|
+
fetchSpinner.succeed(`Issue data fetched (${String(issueData.comments.length)} comment(s))`);
|
|
892
|
+
} else {
|
|
893
|
+
fetchSpinner.stop();
|
|
894
|
+
}
|
|
895
|
+
} catch (err) {
|
|
896
|
+
fetchSpinner.fail("Could not fetch issue data");
|
|
897
|
+
if (err instanceof IssueFetchError) {
|
|
898
|
+
showWarning(`${err.message} \u2014 continuing without issue context`);
|
|
899
|
+
} else if (err instanceof Error) {
|
|
900
|
+
showWarning(`${err.message} \u2014 continuing without issue context`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
const refineDir = getRefinementDir(id, ticket.id);
|
|
905
|
+
await mkdir(refineDir, { recursive: true });
|
|
906
|
+
const outputFile = join4(refineDir, "requirements.json");
|
|
907
|
+
const ticketContent = formatTicketForPrompt(ticket);
|
|
908
|
+
const prompt = buildTicketRefinePrompt(ticketContent, outputFile, schema, issueContext);
|
|
909
|
+
log.dim(`Working directory: ${refineDir}`);
|
|
910
|
+
log.dim(`Requirements output: ${outputFile}`);
|
|
911
|
+
log.newline();
|
|
912
|
+
const spinner = createSpinner(`Starting ${providerName} session...`);
|
|
913
|
+
spinner.start();
|
|
914
|
+
try {
|
|
915
|
+
await runAiSession(refineDir, prompt, ticket.title);
|
|
916
|
+
spinner.succeed(`${providerName} session completed`);
|
|
917
|
+
} catch (err) {
|
|
918
|
+
spinner.fail(`${providerName} session failed`);
|
|
919
|
+
if (err instanceof Error) {
|
|
920
|
+
showError(err.message);
|
|
921
|
+
}
|
|
922
|
+
log.newline();
|
|
923
|
+
skipped++;
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
log.newline();
|
|
927
|
+
if (await fileExists(outputFile)) {
|
|
928
|
+
let content;
|
|
929
|
+
try {
|
|
930
|
+
content = await readFile(outputFile, "utf-8");
|
|
931
|
+
} catch {
|
|
932
|
+
showError(`Failed to read requirements file: ${outputFile}`);
|
|
933
|
+
log.newline();
|
|
934
|
+
skipped++;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
let refinedRequirements;
|
|
938
|
+
try {
|
|
939
|
+
refinedRequirements = parseRequirementsFile(content);
|
|
940
|
+
} catch (err) {
|
|
941
|
+
if (err instanceof Error) {
|
|
942
|
+
showError(`Failed to parse requirements file: ${err.message}`);
|
|
943
|
+
}
|
|
944
|
+
log.newline();
|
|
945
|
+
skipped++;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (refinedRequirements.length === 0) {
|
|
949
|
+
showWarning("No requirements found in output file.");
|
|
950
|
+
log.newline();
|
|
951
|
+
skipped++;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const matchingRequirements = refinedRequirements.filter((r) => r.ref === ticket.id || r.ref === ticket.title);
|
|
955
|
+
if (matchingRequirements.length === 0) {
|
|
956
|
+
showWarning("Requirement reference does not match this ticket.");
|
|
957
|
+
log.newline();
|
|
958
|
+
skipped++;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
const requirement = matchingRequirements.length === 1 ? {
|
|
962
|
+
ref: matchingRequirements[0]?.ref ?? "",
|
|
963
|
+
requirements: matchingRequirements[0]?.requirements ?? ""
|
|
964
|
+
} : {
|
|
965
|
+
ref: matchingRequirements[0]?.ref ?? "",
|
|
966
|
+
requirements: matchingRequirements.map((r, idx) => {
|
|
967
|
+
const text = r.requirements.trim();
|
|
968
|
+
if (/^#\s/.test(text)) return text;
|
|
969
|
+
return `# ${String(idx + 1)}. Section ${String(idx + 1)}
|
|
970
|
+
|
|
971
|
+
${text}`;
|
|
972
|
+
}).join("\n\n---\n\n")
|
|
973
|
+
};
|
|
974
|
+
const reqLines = requirement.requirements.split("\n");
|
|
975
|
+
console.log(renderCard(`${icons.ticket} Refined Requirements`, reqLines));
|
|
976
|
+
log.newline();
|
|
977
|
+
const approveRequirement = await confirm({
|
|
978
|
+
message: `${emoji.donut} Approve these requirements?`,
|
|
979
|
+
default: true
|
|
980
|
+
});
|
|
981
|
+
if (approveRequirement) {
|
|
982
|
+
const ticketIdx = sprint.tickets.findIndex((t) => t.id === ticket.id);
|
|
983
|
+
const ticketToSave = sprint.tickets[ticketIdx];
|
|
984
|
+
if (ticketIdx !== -1 && ticketToSave) {
|
|
985
|
+
ticketToSave.requirements = requirement.requirements;
|
|
986
|
+
ticketToSave.requirementStatus = "approved";
|
|
987
|
+
}
|
|
988
|
+
await saveSprint(sprint);
|
|
989
|
+
showSuccess("Requirements approved and saved!");
|
|
990
|
+
approved++;
|
|
991
|
+
} else {
|
|
992
|
+
log.dim("Requirements not approved. You can refine this ticket later.");
|
|
993
|
+
skipped++;
|
|
994
|
+
}
|
|
995
|
+
} else {
|
|
996
|
+
showWarning("No requirements file found from AI session.");
|
|
997
|
+
log.dim("You can refine this ticket later.");
|
|
998
|
+
skipped++;
|
|
999
|
+
}
|
|
1000
|
+
log.newline();
|
|
1001
|
+
}
|
|
1002
|
+
printSeparator(60);
|
|
1003
|
+
log.newline();
|
|
1004
|
+
printHeader("Summary", icons.success);
|
|
1005
|
+
console.log(field("Approved", String(approved)));
|
|
1006
|
+
console.log(field("Skipped", String(skipped)));
|
|
1007
|
+
console.log(field("Total", String(pendingTickets.length)));
|
|
1008
|
+
log.newline();
|
|
1009
|
+
const updatedSprint = await getSprint(id);
|
|
1010
|
+
const remainingPending = getPendingRequirements(updatedSprint.tickets);
|
|
1011
|
+
if (remainingPending.length === 0) {
|
|
1012
|
+
showSuccess("All requirements approved!");
|
|
1013
|
+
const sprintDir = getSprintDir(id);
|
|
1014
|
+
const outputPath = join4(sprintDir, "requirements.md");
|
|
1015
|
+
try {
|
|
1016
|
+
await exportRequirementsToMarkdown(updatedSprint, outputPath);
|
|
1017
|
+
log.dim(`Requirements saved to: ${outputPath}`);
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
if (err instanceof Error) {
|
|
1020
|
+
showError(`Failed to write requirements: ${err.message}`);
|
|
1021
|
+
} else {
|
|
1022
|
+
showError("Failed to write requirements: Unknown error");
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
showTip('Run "ralphctl sprint plan" to generate tasks.');
|
|
1026
|
+
} else {
|
|
1027
|
+
log.info(`${String(remainingPending.length)} ticket(s) still pending.`);
|
|
1028
|
+
showTip("Continue refinement with: ralphctl sprint refine");
|
|
1029
|
+
}
|
|
1030
|
+
log.newline();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// src/commands/sprint/plan.ts
|
|
1034
|
+
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
1035
|
+
import { join as join5 } from "path";
|
|
1036
|
+
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
1037
|
+
|
|
1038
|
+
// src/store/task.ts
|
|
1039
|
+
var TaskNotFoundError = class extends Error {
|
|
1040
|
+
taskId;
|
|
1041
|
+
constructor(taskId) {
|
|
1042
|
+
super(`Task not found: ${taskId}`);
|
|
1043
|
+
this.name = "TaskNotFoundError";
|
|
1044
|
+
this.taskId = taskId;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
var DependencyCycleError = class extends Error {
|
|
1048
|
+
cycle;
|
|
1049
|
+
constructor(cycle) {
|
|
1050
|
+
super(`Dependency cycle detected: ${cycle.join(" \u2192 ")}`);
|
|
1051
|
+
this.name = "DependencyCycleError";
|
|
1052
|
+
this.cycle = cycle;
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
async function getTasks(sprintId) {
|
|
1056
|
+
const id = await resolveSprintId(sprintId);
|
|
1057
|
+
return readValidatedJson(getTasksFilePath(id), TasksSchema);
|
|
1058
|
+
}
|
|
1059
|
+
async function saveTasks(tasks, sprintId) {
|
|
1060
|
+
const id = await resolveSprintId(sprintId);
|
|
1061
|
+
await writeValidatedJson(getTasksFilePath(id), tasks, TasksSchema);
|
|
1062
|
+
}
|
|
1063
|
+
async function getTask(taskId, sprintId) {
|
|
1064
|
+
const tasks = await getTasks(sprintId);
|
|
1065
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
1066
|
+
if (!task) {
|
|
1067
|
+
throw new TaskNotFoundError(taskId);
|
|
1068
|
+
}
|
|
1069
|
+
return task;
|
|
1070
|
+
}
|
|
1071
|
+
async function addTask(input3, sprintId) {
|
|
1072
|
+
const id = await resolveSprintId(sprintId);
|
|
1073
|
+
const sprint = await getSprint(id);
|
|
1074
|
+
assertSprintStatus(sprint, ["draft"], "add tasks");
|
|
1075
|
+
const tasksFilePath = getTasksFilePath(id);
|
|
1076
|
+
return withFileLock(tasksFilePath, async () => {
|
|
1077
|
+
const tasks = await getTasks(id);
|
|
1078
|
+
const maxOrder = tasks.reduce((max, t) => Math.max(max, t.order), 0);
|
|
1079
|
+
const task = {
|
|
1080
|
+
id: generateUuid8(),
|
|
1081
|
+
name: input3.name,
|
|
1082
|
+
description: input3.description,
|
|
1083
|
+
steps: input3.steps ?? [],
|
|
1084
|
+
status: "todo",
|
|
1085
|
+
order: maxOrder + 1,
|
|
1086
|
+
ticketId: input3.ticketId,
|
|
1087
|
+
blockedBy: input3.blockedBy ?? [],
|
|
1088
|
+
projectPath: input3.projectPath,
|
|
1089
|
+
verified: false
|
|
1090
|
+
};
|
|
1091
|
+
tasks.push(task);
|
|
1092
|
+
await saveTasks(tasks, id);
|
|
1093
|
+
return task;
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
async function removeTask(taskId, sprintId) {
|
|
1097
|
+
const id = await resolveSprintId(sprintId);
|
|
1098
|
+
const sprint = await getSprint(id);
|
|
1099
|
+
assertSprintStatus(sprint, ["draft"], "remove tasks");
|
|
1100
|
+
const tasksFilePath = getTasksFilePath(id);
|
|
1101
|
+
await withFileLock(tasksFilePath, async () => {
|
|
1102
|
+
const tasks = await getTasks(id);
|
|
1103
|
+
const index = tasks.findIndex((t) => t.id === taskId);
|
|
1104
|
+
if (index === -1) {
|
|
1105
|
+
throw new TaskNotFoundError(taskId);
|
|
1106
|
+
}
|
|
1107
|
+
tasks.splice(index, 1);
|
|
1108
|
+
await saveTasks(tasks, id);
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
async function updateTaskStatus(taskId, status, sprintId) {
|
|
1112
|
+
const id = await resolveSprintId(sprintId);
|
|
1113
|
+
const sprint = await getSprint(id);
|
|
1114
|
+
assertSprintStatus(sprint, ["active"], "update task status");
|
|
1115
|
+
const tasksFilePath = getTasksFilePath(id);
|
|
1116
|
+
return withFileLock(tasksFilePath, async () => {
|
|
1117
|
+
const tasks = await getTasks(id);
|
|
1118
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
1119
|
+
if (!task) {
|
|
1120
|
+
throw new TaskNotFoundError(taskId);
|
|
1121
|
+
}
|
|
1122
|
+
task.status = status;
|
|
1123
|
+
await saveTasks(tasks, id);
|
|
1124
|
+
return task;
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
async function updateTask(taskId, updates, sprintId) {
|
|
1128
|
+
const id = await resolveSprintId(sprintId);
|
|
1129
|
+
const sprint = await getSprint(id);
|
|
1130
|
+
assertSprintStatus(sprint, ["active"], "update task");
|
|
1131
|
+
const tasksFilePath = getTasksFilePath(id);
|
|
1132
|
+
return withFileLock(tasksFilePath, async () => {
|
|
1133
|
+
const tasks = await getTasks(id);
|
|
1134
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
1135
|
+
if (!task) {
|
|
1136
|
+
throw new TaskNotFoundError(taskId);
|
|
1137
|
+
}
|
|
1138
|
+
if (updates.verified !== void 0) {
|
|
1139
|
+
task.verified = updates.verified;
|
|
1140
|
+
}
|
|
1141
|
+
if (updates.verificationOutput !== void 0) {
|
|
1142
|
+
task.verificationOutput = updates.verificationOutput;
|
|
1143
|
+
}
|
|
1144
|
+
await saveTasks(tasks, id);
|
|
1145
|
+
return task;
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
async function isTaskBlocked(taskId, sprintId) {
|
|
1149
|
+
const tasks = await getTasks(sprintId);
|
|
1150
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
1151
|
+
if (!task) return false;
|
|
1152
|
+
if (task.blockedBy.length === 0) return false;
|
|
1153
|
+
const doneIds = new Set(tasks.filter((t) => t.status === "done").map((t) => t.id));
|
|
1154
|
+
return !task.blockedBy.every((id) => doneIds.has(id));
|
|
1155
|
+
}
|
|
1156
|
+
async function getNextTask(sprintId) {
|
|
1157
|
+
const tasks = await getTasks(sprintId);
|
|
1158
|
+
const inProgress = tasks.find((t) => t.status === "in_progress");
|
|
1159
|
+
if (inProgress) {
|
|
1160
|
+
return inProgress;
|
|
1161
|
+
}
|
|
1162
|
+
const ready = getReadyTasksFromList(tasks);
|
|
1163
|
+
return ready[0] ?? null;
|
|
1164
|
+
}
|
|
1165
|
+
function getReadyTasksFromList(tasks) {
|
|
1166
|
+
const doneIds = new Set(tasks.filter((t) => t.status === "done").map((t) => t.id));
|
|
1167
|
+
return tasks.filter((t) => t.status === "todo").filter((t) => t.blockedBy.every((id) => doneIds.has(id))).sort((a, b) => a.order - b.order);
|
|
1168
|
+
}
|
|
1169
|
+
async function getReadyTasks(sprintId) {
|
|
1170
|
+
const tasks = await getTasks(sprintId);
|
|
1171
|
+
return getReadyTasksFromList(tasks);
|
|
1172
|
+
}
|
|
1173
|
+
async function reorderTask(taskId, newOrder, sprintId) {
|
|
1174
|
+
const id = await resolveSprintId(sprintId);
|
|
1175
|
+
const sprint = await getSprint(id);
|
|
1176
|
+
assertSprintStatus(sprint, ["draft"], "reorder tasks");
|
|
1177
|
+
const tasksFilePath = getTasksFilePath(id);
|
|
1178
|
+
return withFileLock(tasksFilePath, async () => {
|
|
1179
|
+
const tasks = await getTasks(id);
|
|
1180
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
1181
|
+
if (!task) {
|
|
1182
|
+
throw new TaskNotFoundError(taskId);
|
|
1183
|
+
}
|
|
1184
|
+
const oldOrder = task.order;
|
|
1185
|
+
task.order = newOrder;
|
|
1186
|
+
for (const t of tasks) {
|
|
1187
|
+
if (t.id === taskId) continue;
|
|
1188
|
+
if (oldOrder < newOrder) {
|
|
1189
|
+
if (t.order > oldOrder && t.order <= newOrder) {
|
|
1190
|
+
t.order--;
|
|
1191
|
+
}
|
|
1192
|
+
} else {
|
|
1193
|
+
if (t.order >= newOrder && t.order < oldOrder) {
|
|
1194
|
+
t.order++;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
await saveTasks(tasks, id);
|
|
1199
|
+
return task;
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
async function listTasks(sprintId) {
|
|
1203
|
+
const tasks = await getTasks(sprintId);
|
|
1204
|
+
return tasks.sort((a, b) => a.order - b.order);
|
|
1205
|
+
}
|
|
1206
|
+
async function getRemainingTasks(sprintId) {
|
|
1207
|
+
const tasks = await getTasks(sprintId);
|
|
1208
|
+
return tasks.filter((t) => t.status !== "done").sort((a, b) => a.order - b.order);
|
|
1209
|
+
}
|
|
1210
|
+
async function areAllTasksDone(sprintId) {
|
|
1211
|
+
const tasks = await getTasks(sprintId);
|
|
1212
|
+
return tasks.length > 0 && tasks.every((t) => t.status === "done");
|
|
1213
|
+
}
|
|
1214
|
+
function topologicalSort(tasks) {
|
|
1215
|
+
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
1216
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1217
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1218
|
+
const result = [];
|
|
1219
|
+
function visit(taskId, path) {
|
|
1220
|
+
if (visited.has(taskId)) return;
|
|
1221
|
+
if (visiting.has(taskId)) {
|
|
1222
|
+
const cycleStart = path.indexOf(taskId);
|
|
1223
|
+
throw new DependencyCycleError([...path.slice(cycleStart), taskId]);
|
|
1224
|
+
}
|
|
1225
|
+
const task = taskMap.get(taskId);
|
|
1226
|
+
if (!task) return;
|
|
1227
|
+
visiting.add(taskId);
|
|
1228
|
+
for (const blockedById of task.blockedBy) {
|
|
1229
|
+
visit(blockedById, [...path, taskId]);
|
|
1230
|
+
}
|
|
1231
|
+
visiting.delete(taskId);
|
|
1232
|
+
visited.add(taskId);
|
|
1233
|
+
result.push(task);
|
|
1234
|
+
}
|
|
1235
|
+
for (const task of tasks) {
|
|
1236
|
+
visit(task.id, []);
|
|
1237
|
+
}
|
|
1238
|
+
return result;
|
|
1239
|
+
}
|
|
1240
|
+
async function reorderByDependencies(sprintId) {
|
|
1241
|
+
const id = await resolveSprintId(sprintId);
|
|
1242
|
+
const tasksFilePath = getTasksFilePath(id);
|
|
1243
|
+
await withFileLock(tasksFilePath, async () => {
|
|
1244
|
+
const tasks = await getTasks(id);
|
|
1245
|
+
if (tasks.length === 0) return;
|
|
1246
|
+
const sorted = topologicalSort(tasks);
|
|
1247
|
+
sorted.forEach((task, index) => {
|
|
1248
|
+
task.order = index + 1;
|
|
1249
|
+
});
|
|
1250
|
+
await saveTasks(sorted, id);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
function validateImportTasks(importTasks2, existingTasks, ticketIds) {
|
|
1254
|
+
const errors = [];
|
|
1255
|
+
if (ticketIds) {
|
|
1256
|
+
for (const task of importTasks2) {
|
|
1257
|
+
if (task.ticketId && !ticketIds.has(task.ticketId)) {
|
|
1258
|
+
errors.push(`Task "${task.name}": ticketId "${task.ticketId}" does not match any ticket in the sprint`);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
const localIds = new Set(importTasks2.map((t) => t.id).filter((id) => !!id));
|
|
1263
|
+
const existingIds = new Set(existingTasks.map((t) => t.id));
|
|
1264
|
+
const allKnownIds = /* @__PURE__ */ new Set([...localIds, ...existingIds]);
|
|
1265
|
+
const localIdToIndex = /* @__PURE__ */ new Map();
|
|
1266
|
+
importTasks2.forEach((task, i) => {
|
|
1267
|
+
if (task.id) {
|
|
1268
|
+
localIdToIndex.set(task.id, i);
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
importTasks2.forEach((task, taskIndex) => {
|
|
1272
|
+
for (const depId of task.blockedBy ?? []) {
|
|
1273
|
+
if (!allKnownIds.has(depId)) {
|
|
1274
|
+
errors.push(`Task "${task.name}": blockedBy "${depId}" does not exist`);
|
|
1275
|
+
} else if (localIds.has(depId)) {
|
|
1276
|
+
const depIndex = localIdToIndex.get(depId);
|
|
1277
|
+
if (depIndex !== void 0 && depIndex >= taskIndex) {
|
|
1278
|
+
errors.push(`Task "${task.name}": blockedBy "${depId}" must reference an earlier task in the import`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
if (errors.length > 0) {
|
|
1284
|
+
return errors;
|
|
1285
|
+
}
|
|
1286
|
+
const tempRealIds = importTasks2.map(() => generateUuid8());
|
|
1287
|
+
const localToTempReal = /* @__PURE__ */ new Map();
|
|
1288
|
+
importTasks2.forEach((task, i) => {
|
|
1289
|
+
if (task.id) {
|
|
1290
|
+
localToTempReal.set(task.id, tempRealIds[i] ?? "");
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
const combinedTasks = [
|
|
1294
|
+
...existingTasks,
|
|
1295
|
+
...importTasks2.map((t, i) => ({
|
|
1296
|
+
id: tempRealIds[i] ?? generateUuid8(),
|
|
1297
|
+
name: t.name,
|
|
1298
|
+
description: void 0,
|
|
1299
|
+
steps: [],
|
|
1300
|
+
status: "todo",
|
|
1301
|
+
order: existingTasks.length + i + 1,
|
|
1302
|
+
ticketId: void 0,
|
|
1303
|
+
blockedBy: (t.blockedBy ?? []).map((depId) => {
|
|
1304
|
+
return localToTempReal.get(depId) ?? depId;
|
|
1305
|
+
}),
|
|
1306
|
+
projectPath: "/tmp",
|
|
1307
|
+
// Placeholder for validation only
|
|
1308
|
+
verified: false
|
|
1309
|
+
}))
|
|
1310
|
+
];
|
|
1311
|
+
try {
|
|
1312
|
+
topologicalSort(combinedTasks);
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
if (err instanceof DependencyCycleError) {
|
|
1315
|
+
errors.push(err.message);
|
|
1316
|
+
} else {
|
|
1317
|
+
throw err;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return errors;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// src/interactive/selectors.ts
|
|
1324
|
+
import { checkbox, confirm as confirm2, input } from "@inquirer/prompts";
|
|
1325
|
+
async function selectProject(message = "Select project:") {
|
|
1326
|
+
const projects = await listProjects();
|
|
1327
|
+
if (projects.length === 0) {
|
|
1328
|
+
console.log(muted("\nNo projects found."));
|
|
1329
|
+
const create = await confirm2({
|
|
1330
|
+
message: "Create one now?",
|
|
1331
|
+
default: true
|
|
1332
|
+
});
|
|
1333
|
+
if (create) {
|
|
1334
|
+
const { projectAddCommand } = await import("./add-MRGCS3US.mjs");
|
|
1335
|
+
await projectAddCommand({ interactive: true });
|
|
1336
|
+
const updated = await listProjects();
|
|
1337
|
+
if (updated.length === 0) return null;
|
|
1338
|
+
if (updated.length === 1 && updated[0]) return updated[0].name;
|
|
1339
|
+
return escapableSelect({
|
|
1340
|
+
message: `${emoji.donut} ${message}`,
|
|
1341
|
+
choices: updated.map((p) => ({
|
|
1342
|
+
name: p.displayName,
|
|
1343
|
+
value: p.name,
|
|
1344
|
+
description: p.description
|
|
1345
|
+
}))
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
return escapableSelect({
|
|
1351
|
+
message: `${emoji.donut} ${message}`,
|
|
1352
|
+
choices: projects.map((p) => ({
|
|
1353
|
+
name: p.displayName,
|
|
1354
|
+
value: p.name,
|
|
1355
|
+
description: p.description
|
|
1356
|
+
}))
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
async function selectProjectRepository(message = "Select repository:") {
|
|
1360
|
+
const projects = await listProjects();
|
|
1361
|
+
if (projects.length === 0) {
|
|
1362
|
+
console.log(muted("\nNo projects found.\n"));
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
let projectName;
|
|
1366
|
+
const firstProject = projects[0];
|
|
1367
|
+
if (projects.length === 1 && firstProject) {
|
|
1368
|
+
projectName = firstProject.name;
|
|
1369
|
+
} else {
|
|
1370
|
+
projectName = await escapableSelect({
|
|
1371
|
+
message: `${emoji.donut} Select project:`,
|
|
1372
|
+
choices: projects.map((p) => ({
|
|
1373
|
+
name: p.displayName,
|
|
1374
|
+
value: p.name,
|
|
1375
|
+
description: `${String(p.repositories.length)} repo(s)`
|
|
1376
|
+
}))
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
if (!projectName) return null;
|
|
1380
|
+
const project = projects.find((p) => p.name === projectName);
|
|
1381
|
+
if (!project) {
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
const firstRepo = project.repositories[0];
|
|
1385
|
+
if (project.repositories.length === 1 && firstRepo) {
|
|
1386
|
+
return firstRepo.path;
|
|
1387
|
+
}
|
|
1388
|
+
return escapableSelect({
|
|
1389
|
+
message: `${emoji.donut} ${message}`,
|
|
1390
|
+
choices: project.repositories.map((r) => ({
|
|
1391
|
+
name: r.name,
|
|
1392
|
+
value: r.path,
|
|
1393
|
+
description: r.path
|
|
1394
|
+
}))
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
async function selectSprint(message = "Select sprint:", filter) {
|
|
1398
|
+
const sprints = await listSprints();
|
|
1399
|
+
const filtered = filter ? sprints.filter((s) => filter.includes(s.status)) : sprints;
|
|
1400
|
+
if (filtered.length === 0) {
|
|
1401
|
+
console.log(muted("\nNo sprints found."));
|
|
1402
|
+
const create = await confirm2({
|
|
1403
|
+
message: "Create one now?",
|
|
1404
|
+
default: true
|
|
1405
|
+
});
|
|
1406
|
+
if (create) {
|
|
1407
|
+
const { sprintCreateCommand } = await import("./create-MG7E7PLQ.mjs");
|
|
1408
|
+
await sprintCreateCommand({ interactive: true });
|
|
1409
|
+
const updated = await listSprints();
|
|
1410
|
+
const refiltered = filter ? updated.filter((s) => filter.includes(s.status)) : updated;
|
|
1411
|
+
if (refiltered.length === 0) return null;
|
|
1412
|
+
if (refiltered.length === 1 && refiltered[0]) return refiltered[0].id;
|
|
1413
|
+
return escapableSelect({
|
|
1414
|
+
message: `${emoji.donut} ${message}`,
|
|
1415
|
+
choices: refiltered.map((s) => ({
|
|
1416
|
+
name: `${s.id} - ${s.name} (${formatSprintStatus(s.status)})`,
|
|
1417
|
+
value: s.id
|
|
1418
|
+
}))
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
return null;
|
|
1422
|
+
}
|
|
1423
|
+
return escapableSelect({
|
|
1424
|
+
message: `${emoji.donut} ${message}`,
|
|
1425
|
+
choices: filtered.map((s) => ({
|
|
1426
|
+
name: `${s.id} - ${s.name} (${formatSprintStatus(s.status)})`,
|
|
1427
|
+
value: s.id
|
|
1428
|
+
}))
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
async function selectTicket(message = "Select ticket:", filter) {
|
|
1432
|
+
const tickets = await listTickets();
|
|
1433
|
+
const filtered = filter ? tickets.filter(filter) : tickets;
|
|
1434
|
+
if (filtered.length === 0) {
|
|
1435
|
+
if (tickets.length === 0) {
|
|
1436
|
+
console.log(muted("\nNo tickets found."));
|
|
1437
|
+
const create = await confirm2({
|
|
1438
|
+
message: "Add one now?",
|
|
1439
|
+
default: true
|
|
1440
|
+
});
|
|
1441
|
+
if (create) {
|
|
1442
|
+
const { ticketAddCommand } = await import("./add-HGJCLWED.mjs");
|
|
1443
|
+
await ticketAddCommand({ interactive: true });
|
|
1444
|
+
const updated = await listTickets();
|
|
1445
|
+
const refiltered = filter ? updated.filter(filter) : updated;
|
|
1446
|
+
if (refiltered.length === 0) return null;
|
|
1447
|
+
if (refiltered.length === 1 && refiltered[0]) return refiltered[0].id;
|
|
1448
|
+
return escapableSelect({
|
|
1449
|
+
message: `${emoji.donut} ${message}`,
|
|
1450
|
+
choices: refiltered.map((t) => ({
|
|
1451
|
+
name: formatTicketDisplay(t),
|
|
1452
|
+
value: t.id
|
|
1453
|
+
}))
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
console.log(muted("\nNo matching tickets found.\n"));
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
return escapableSelect({
|
|
1462
|
+
message: `${emoji.donut} ${message}`,
|
|
1463
|
+
choices: filtered.map((t) => ({
|
|
1464
|
+
name: formatTicketDisplay(t),
|
|
1465
|
+
value: t.id
|
|
1466
|
+
}))
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
async function selectTask(message = "Select task:", filter) {
|
|
1470
|
+
const tasks = await listTasks();
|
|
1471
|
+
const filtered = filter ? tasks.filter((t) => filter.includes(t.status)) : tasks;
|
|
1472
|
+
if (filtered.length === 0) {
|
|
1473
|
+
console.log(muted('\nNo tasks found. Use "sprint plan" to generate tasks.\n'));
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
return escapableSelect({
|
|
1477
|
+
message: `${emoji.donut} ${message}`,
|
|
1478
|
+
choices: filtered.map((t) => ({
|
|
1479
|
+
name: `${formatTaskStatus(t.status)} ${t.name}`,
|
|
1480
|
+
value: t.id
|
|
1481
|
+
}))
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
async function selectTaskStatus(message = "Select status:") {
|
|
1485
|
+
const statuses = ["todo", "in_progress", "done"];
|
|
1486
|
+
return escapableSelect({
|
|
1487
|
+
message: `${emoji.donut} ${message}`,
|
|
1488
|
+
choices: statuses.map((s) => ({
|
|
1489
|
+
name: formatTaskStatus(s),
|
|
1490
|
+
value: s
|
|
1491
|
+
}))
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
async function inputPositiveInt(message) {
|
|
1495
|
+
const value = await input({
|
|
1496
|
+
message: `${emoji.donut} ${message}`,
|
|
1497
|
+
validate: (v) => {
|
|
1498
|
+
const n = parseInt(v, 10);
|
|
1499
|
+
if (isNaN(n) || n < 1) return "Must be a positive integer";
|
|
1500
|
+
return true;
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
return parseInt(value, 10);
|
|
1504
|
+
}
|
|
1505
|
+
async function selectProjectPaths(reposByProject, message = "Select paths to explore:", preSelected) {
|
|
1506
|
+
const choices = [];
|
|
1507
|
+
const preSelectedSet = preSelected ? new Set(preSelected) : null;
|
|
1508
|
+
for (const [projectName, repos] of reposByProject) {
|
|
1509
|
+
repos.forEach((repo, i) => {
|
|
1510
|
+
choices.push({
|
|
1511
|
+
name: `[${projectName}] ${repo.name} (${repo.path})`,
|
|
1512
|
+
value: repo.path,
|
|
1513
|
+
checked: preSelectedSet ? preSelectedSet.has(repo.path) : i === 0
|
|
1514
|
+
});
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
return checkbox({ message: `${emoji.donut} ${message}`, choices });
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// src/commands/sprint/plan-utils.ts
|
|
1521
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
1522
|
+
async function getTaskImportSchema() {
|
|
1523
|
+
const schemaPath = getSchemaPath("task-import.schema.json");
|
|
1524
|
+
return readFile2(schemaPath, "utf-8");
|
|
1525
|
+
}
|
|
1526
|
+
function parsePlanningBlocked(output) {
|
|
1527
|
+
const match = /<planning-blocked>([\s\S]*?)<\/planning-blocked>/.exec(output);
|
|
1528
|
+
return match?.[1]?.trim() ?? null;
|
|
1529
|
+
}
|
|
1530
|
+
function parseTasksJson(output) {
|
|
1531
|
+
const jsonStr = extractJsonArray(output);
|
|
1532
|
+
let parsed;
|
|
1533
|
+
try {
|
|
1534
|
+
parsed = JSON.parse(jsonStr);
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
throw new Error(`Invalid JSON: ${err instanceof Error ? err.message : "parse error"}`, { cause: err });
|
|
1537
|
+
}
|
|
1538
|
+
if (!Array.isArray(parsed)) {
|
|
1539
|
+
throw new Error("Expected JSON array");
|
|
1540
|
+
}
|
|
1541
|
+
const result = ImportTasksSchema.safeParse(parsed);
|
|
1542
|
+
if (!result.success) {
|
|
1543
|
+
const issues = result.error.issues.map((issue) => {
|
|
1544
|
+
const path = issue.path.length > 0 ? `[${issue.path.join(".")}]` : "";
|
|
1545
|
+
return ` ${path}: ${issue.message}`;
|
|
1546
|
+
}).join("\n");
|
|
1547
|
+
throw new Error(`Invalid task format:
|
|
1548
|
+
${issues}`);
|
|
1549
|
+
}
|
|
1550
|
+
return result.data;
|
|
1551
|
+
}
|
|
1552
|
+
function renderParsedTasksTable(parsedTasks) {
|
|
1553
|
+
const rows = parsedTasks.map((task, i) => {
|
|
1554
|
+
const deps = task.blockedBy?.length ? task.blockedBy.join(", ") : "";
|
|
1555
|
+
return [String(i + 1), task.name, task.projectPath, deps];
|
|
1556
|
+
});
|
|
1557
|
+
return renderTable(
|
|
1558
|
+
[{ header: "#", align: "right" }, { header: "Name" }, { header: "Path" }, { header: "Blocked By" }],
|
|
1559
|
+
rows
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
async function importTasks(tasks, sprintId, options) {
|
|
1563
|
+
if (options?.replace) {
|
|
1564
|
+
return importTasksReplace(tasks, sprintId);
|
|
1565
|
+
}
|
|
1566
|
+
return importTasksAppend(tasks, sprintId);
|
|
1567
|
+
}
|
|
1568
|
+
async function importTasksAppend(tasks, sprintId) {
|
|
1569
|
+
const localToRealId = /* @__PURE__ */ new Map();
|
|
1570
|
+
const createdTasks = [];
|
|
1571
|
+
for (const taskInput of tasks) {
|
|
1572
|
+
try {
|
|
1573
|
+
const projectPath = taskInput.projectPath;
|
|
1574
|
+
const task = await addTask(
|
|
1575
|
+
{
|
|
1576
|
+
name: taskInput.name,
|
|
1577
|
+
description: taskInput.description,
|
|
1578
|
+
steps: taskInput.steps ?? [],
|
|
1579
|
+
ticketId: taskInput.ticketId,
|
|
1580
|
+
blockedBy: [],
|
|
1581
|
+
// Set later
|
|
1582
|
+
projectPath
|
|
1583
|
+
},
|
|
1584
|
+
sprintId
|
|
1585
|
+
);
|
|
1586
|
+
if (taskInput.id) {
|
|
1587
|
+
localToRealId.set(taskInput.id, task.id);
|
|
1588
|
+
}
|
|
1589
|
+
createdTasks.push({ task: taskInput, realId: task.id });
|
|
1590
|
+
log.itemSuccess(`${task.id}: ${task.name}`);
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
log.itemError(`Failed to add: ${taskInput.name}`);
|
|
1593
|
+
if (err instanceof Error) {
|
|
1594
|
+
console.log(muted(` ${err.message}`));
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
const tasksFilePath = getTasksFilePath(sprintId);
|
|
1599
|
+
await withFileLock(tasksFilePath, async () => {
|
|
1600
|
+
const allTasks = await getTasks(sprintId);
|
|
1601
|
+
for (const { task: taskInput, realId } of createdTasks) {
|
|
1602
|
+
const blockedBy = (taskInput.blockedBy ?? []).map((localId) => localToRealId.get(localId) ?? "").filter((id) => id !== "");
|
|
1603
|
+
if (blockedBy.length > 0) {
|
|
1604
|
+
const taskToUpdate = allTasks.find((t) => t.id === realId);
|
|
1605
|
+
if (taskToUpdate) {
|
|
1606
|
+
taskToUpdate.blockedBy = blockedBy;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
await saveTasks(allTasks, sprintId);
|
|
1611
|
+
});
|
|
1612
|
+
return createdTasks.length;
|
|
1613
|
+
}
|
|
1614
|
+
async function importTasksReplace(tasks, sprintId) {
|
|
1615
|
+
const localToRealId = /* @__PURE__ */ new Map();
|
|
1616
|
+
const newTasks = [];
|
|
1617
|
+
for (const taskInput of tasks) {
|
|
1618
|
+
const realId = generateUuid8();
|
|
1619
|
+
if (taskInput.id) {
|
|
1620
|
+
localToRealId.set(taskInput.id, realId);
|
|
1621
|
+
}
|
|
1622
|
+
newTasks.push({
|
|
1623
|
+
id: realId,
|
|
1624
|
+
name: taskInput.name,
|
|
1625
|
+
description: taskInput.description,
|
|
1626
|
+
steps: taskInput.steps ?? [],
|
|
1627
|
+
status: "todo",
|
|
1628
|
+
order: newTasks.length + 1,
|
|
1629
|
+
ticketId: taskInput.ticketId,
|
|
1630
|
+
blockedBy: [],
|
|
1631
|
+
// Set in second pass
|
|
1632
|
+
projectPath: taskInput.projectPath,
|
|
1633
|
+
verified: false
|
|
1634
|
+
});
|
|
1635
|
+
log.itemSuccess(`${realId}: ${taskInput.name}`);
|
|
1636
|
+
}
|
|
1637
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
1638
|
+
const taskInput = tasks[i];
|
|
1639
|
+
const newTask = newTasks[i];
|
|
1640
|
+
if (!taskInput || !newTask) continue;
|
|
1641
|
+
const blockedBy = (taskInput.blockedBy ?? []).map((localId) => localToRealId.get(localId) ?? "").filter((id) => id !== "");
|
|
1642
|
+
newTask.blockedBy = blockedBy;
|
|
1643
|
+
}
|
|
1644
|
+
await saveTasks(newTasks, sprintId);
|
|
1645
|
+
return newTasks.length;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/commands/sprint/plan.ts
|
|
1649
|
+
function parseArgs2(args) {
|
|
1650
|
+
const options = {
|
|
1651
|
+
auto: false,
|
|
1652
|
+
allPaths: false
|
|
1653
|
+
};
|
|
1654
|
+
let sprintId;
|
|
1655
|
+
for (const arg of args) {
|
|
1656
|
+
if (arg === "--auto") {
|
|
1657
|
+
options.auto = true;
|
|
1658
|
+
} else if (arg === "--all-paths") {
|
|
1659
|
+
options.allPaths = true;
|
|
1660
|
+
} else if (!arg.startsWith("-")) {
|
|
1661
|
+
sprintId = arg;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return { sprintId, options };
|
|
1665
|
+
}
|
|
1666
|
+
async function getSprintContext(sprintName, ticketsByProject, existingTasks) {
|
|
1667
|
+
const lines = [];
|
|
1668
|
+
lines.push(`# Sprint: ${sprintName}`);
|
|
1669
|
+
for (const [projectName, tickets] of ticketsByProject) {
|
|
1670
|
+
lines.push("");
|
|
1671
|
+
lines.push(`## Project: ${projectName}`);
|
|
1672
|
+
try {
|
|
1673
|
+
const project = await getProject(projectName);
|
|
1674
|
+
lines.push("");
|
|
1675
|
+
lines.push("### Repositories");
|
|
1676
|
+
for (const repo of project.repositories) {
|
|
1677
|
+
lines.push(`- **${repo.name}**: ${repo.path}`);
|
|
1678
|
+
if (repo.checkScript) {
|
|
1679
|
+
lines.push(` - Check: \`${repo.checkScript}\``);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
} catch {
|
|
1683
|
+
lines.push("Repositories: (project not found)");
|
|
1684
|
+
}
|
|
1685
|
+
lines.push("");
|
|
1686
|
+
lines.push("### Tickets");
|
|
1687
|
+
for (const ticket of tickets) {
|
|
1688
|
+
lines.push("");
|
|
1689
|
+
lines.push(`#### ${formatTicketDisplay(ticket)}`);
|
|
1690
|
+
if (ticket.description) {
|
|
1691
|
+
lines.push("");
|
|
1692
|
+
lines.push("**Original Description:**");
|
|
1693
|
+
lines.push(ticket.description);
|
|
1694
|
+
}
|
|
1695
|
+
if (ticket.link) {
|
|
1696
|
+
lines.push("");
|
|
1697
|
+
lines.push(`Link: ${ticket.link}`);
|
|
1698
|
+
}
|
|
1699
|
+
if (ticket.requirements) {
|
|
1700
|
+
lines.push("");
|
|
1701
|
+
lines.push("**Refined Requirements:**");
|
|
1702
|
+
lines.push("");
|
|
1703
|
+
lines.push(ticket.requirements);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
if (existingTasks.length > 0) {
|
|
1708
|
+
lines.push("");
|
|
1709
|
+
lines.push("## Existing Tasks");
|
|
1710
|
+
lines.push("");
|
|
1711
|
+
lines.push(
|
|
1712
|
+
"> These are tasks from a previous planning run. Your output will replace all existing tasks entirely. You may reuse, modify, or drop existing tasks, and add new ones. Generate a complete task set covering ALL tickets."
|
|
1713
|
+
);
|
|
1714
|
+
lines.push("");
|
|
1715
|
+
for (const task of existingTasks) {
|
|
1716
|
+
const desc = task.description ? ` \u2014 ${task.description}` : "";
|
|
1717
|
+
const ticket = task.ticketId ? ` ticket:${task.ticketId}` : "";
|
|
1718
|
+
lines.push(`- ${task.id}: ${task.name} [${task.status}] (${task.projectPath})${ticket}${desc}`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return lines.join("\n");
|
|
1722
|
+
}
|
|
1723
|
+
async function invokeAiInteractive(prompt, repoPaths, planDir) {
|
|
1724
|
+
const contextFile = join5(planDir, "planning-context.md");
|
|
1725
|
+
await writeFile3(contextFile, prompt, "utf-8");
|
|
1726
|
+
const provider = await getActiveProvider();
|
|
1727
|
+
const ticketCount = (prompt.match(/^####/gm) ?? []).length;
|
|
1728
|
+
const startPrompt = `I need help planning tasks for a sprint. The full planning context is in planning-context.md (${String(ticketCount)} tickets). Please read that file now and follow the instructions to help me plan implementation tasks.`;
|
|
1729
|
+
const args = ["--add-dir", ...repoPaths];
|
|
1730
|
+
const result = spawnInteractive(
|
|
1731
|
+
startPrompt,
|
|
1732
|
+
{
|
|
1733
|
+
cwd: planDir,
|
|
1734
|
+
args,
|
|
1735
|
+
env: provider.getSpawnEnv()
|
|
1736
|
+
},
|
|
1737
|
+
provider
|
|
1738
|
+
);
|
|
1739
|
+
if (result.error) {
|
|
1740
|
+
throw new Error(result.error);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
async function invokeAiAuto(prompt, repoPaths, planDir) {
|
|
1744
|
+
const provider = await getActiveProvider();
|
|
1745
|
+
const args = ["--permission-mode", "plan", "--print"];
|
|
1746
|
+
for (const path of repoPaths) {
|
|
1747
|
+
args.push("--add-dir", path);
|
|
1748
|
+
}
|
|
1749
|
+
args.push("-p", prompt);
|
|
1750
|
+
return spawnHeadless(
|
|
1751
|
+
{
|
|
1752
|
+
cwd: planDir,
|
|
1753
|
+
args,
|
|
1754
|
+
env: provider.getSpawnEnv()
|
|
1755
|
+
},
|
|
1756
|
+
provider
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
async function sprintPlanCommand(args) {
|
|
1760
|
+
const { sprintId, options } = parseArgs2(args);
|
|
1761
|
+
let id;
|
|
1762
|
+
try {
|
|
1763
|
+
id = await resolveSprintId(sprintId);
|
|
1764
|
+
} catch {
|
|
1765
|
+
showWarning("No sprint specified and no current sprint set.");
|
|
1766
|
+
showNextStep("ralphctl sprint create", "create a new sprint");
|
|
1767
|
+
log.newline();
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const sprint = await getSprint(id);
|
|
1771
|
+
try {
|
|
1772
|
+
assertSprintStatus(sprint, ["draft"], "plan");
|
|
1773
|
+
} catch (err) {
|
|
1774
|
+
if (err instanceof Error) {
|
|
1775
|
+
showError(err.message);
|
|
1776
|
+
log.newline();
|
|
1777
|
+
}
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
if (sprint.tickets.length === 0) {
|
|
1781
|
+
showWarning("No tickets in sprint.");
|
|
1782
|
+
showNextStep("ralphctl ticket add --project <project-name>", "add tickets first");
|
|
1783
|
+
log.newline();
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const ticketsToProcess = sprint.tickets;
|
|
1787
|
+
if (!allRequirementsApproved(ticketsToProcess)) {
|
|
1788
|
+
const pendingTickets = getPendingRequirements(ticketsToProcess);
|
|
1789
|
+
showWarning("Not all tickets have approved requirements.");
|
|
1790
|
+
log.dim(`Pending: ${String(pendingTickets.length)} ticket(s)`);
|
|
1791
|
+
for (const ticket of pendingTickets) {
|
|
1792
|
+
log.item(muted(formatTicketDisplay(ticket)));
|
|
1793
|
+
}
|
|
1794
|
+
showNextStep("ralphctl sprint refine", "refine requirements first");
|
|
1795
|
+
log.newline();
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const existingTasks = await listTasks(id);
|
|
1799
|
+
const isReplan = existingTasks.length > 0;
|
|
1800
|
+
if (isReplan) {
|
|
1801
|
+
if (options.auto) {
|
|
1802
|
+
showInfo(`Re-plan: ${String(existingTasks.length)} existing task(s) will be replaced with a fresh plan.`);
|
|
1803
|
+
log.newline();
|
|
1804
|
+
} else {
|
|
1805
|
+
const proceed = await confirm3({
|
|
1806
|
+
message: `${emoji.donut} ${String(existingTasks.length)} task(s) already exist. Re-planning will replace all tasks. Continue?`,
|
|
1807
|
+
default: true
|
|
1808
|
+
});
|
|
1809
|
+
if (!proceed) {
|
|
1810
|
+
log.dim("Cancelled.");
|
|
1811
|
+
log.newline();
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
const ticketsByProject = groupTicketsByProject(ticketsToProcess);
|
|
1817
|
+
const providerName = providerDisplayName(await resolveProvider());
|
|
1818
|
+
const modeLabel = options.auto ? "Auto (headless)" : "Interactive";
|
|
1819
|
+
printHeader("Sprint Planning", icons.sprint);
|
|
1820
|
+
console.log(field("Sprint", sprint.name));
|
|
1821
|
+
console.log(field("ID", sprint.id));
|
|
1822
|
+
console.log(field("Tickets", String(ticketsToProcess.length)));
|
|
1823
|
+
console.log(field("Projects", String(ticketsByProject.size)));
|
|
1824
|
+
console.log(field("Mode", modeLabel));
|
|
1825
|
+
console.log(field("Provider", providerName));
|
|
1826
|
+
for (const [proj, tickets] of ticketsByProject) {
|
|
1827
|
+
console.log(muted(` - ${proj}: ${String(tickets.length)} ticket(s)`));
|
|
1828
|
+
}
|
|
1829
|
+
console.log("");
|
|
1830
|
+
const reposByProject = /* @__PURE__ */ new Map();
|
|
1831
|
+
const defaultPaths = [];
|
|
1832
|
+
for (const ticket of ticketsToProcess) {
|
|
1833
|
+
if (reposByProject.has(ticket.projectName)) continue;
|
|
1834
|
+
try {
|
|
1835
|
+
const project = await getProject(ticket.projectName);
|
|
1836
|
+
reposByProject.set(ticket.projectName, project.repositories);
|
|
1837
|
+
if (project.repositories[0]) defaultPaths.push(project.repositories[0].path);
|
|
1838
|
+
} catch {
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const savedPaths = /* @__PURE__ */ new Set();
|
|
1842
|
+
for (const ticket of ticketsToProcess) {
|
|
1843
|
+
if (ticket.affectedRepositories) {
|
|
1844
|
+
for (const path of ticket.affectedRepositories) {
|
|
1845
|
+
savedPaths.add(path);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
const hasSavedSelection = savedPaths.size > 0;
|
|
1850
|
+
let selectedPaths;
|
|
1851
|
+
const totalRepos = [...reposByProject.values()].reduce((n, repos) => n + repos.length, 0);
|
|
1852
|
+
if (options.allPaths) {
|
|
1853
|
+
selectedPaths = [...reposByProject.values()].flatMap((repos) => repos.map((r) => r.path));
|
|
1854
|
+
} else if (options.auto) {
|
|
1855
|
+
selectedPaths = hasSavedSelection ? [...savedPaths] : defaultPaths;
|
|
1856
|
+
} else if (totalRepos === defaultPaths.length) {
|
|
1857
|
+
selectedPaths = defaultPaths;
|
|
1858
|
+
} else {
|
|
1859
|
+
selectedPaths = await selectProjectPaths(
|
|
1860
|
+
reposByProject,
|
|
1861
|
+
"Select paths to explore:",
|
|
1862
|
+
hasSavedSelection ? [...savedPaths] : void 0
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
for (const ticket of ticketsToProcess) {
|
|
1866
|
+
const projectRepos = reposByProject.get(ticket.projectName);
|
|
1867
|
+
if (projectRepos) {
|
|
1868
|
+
const projectRepoPaths = new Set(projectRepos.map((r) => r.path));
|
|
1869
|
+
ticket.affectedRepositories = selectedPaths.filter((p) => projectRepoPaths.has(p));
|
|
1870
|
+
} else {
|
|
1871
|
+
ticket.affectedRepositories = [];
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
await saveSprint(sprint);
|
|
1875
|
+
if (selectedPaths.length > 1) {
|
|
1876
|
+
console.log(muted(`Paths: ${selectedPaths.join(", ")}`));
|
|
1877
|
+
} else {
|
|
1878
|
+
console.log(muted(`Path: ${selectedPaths[0] ?? process.cwd()}`));
|
|
1879
|
+
}
|
|
1880
|
+
const context = await getSprintContext(
|
|
1881
|
+
sprint.name,
|
|
1882
|
+
ticketsByProject,
|
|
1883
|
+
existingTasks.map((t) => ({
|
|
1884
|
+
id: t.id,
|
|
1885
|
+
name: t.name,
|
|
1886
|
+
description: t.description,
|
|
1887
|
+
status: t.status,
|
|
1888
|
+
ticketId: t.ticketId,
|
|
1889
|
+
projectPath: t.projectPath
|
|
1890
|
+
}))
|
|
1891
|
+
);
|
|
1892
|
+
const schema = await getTaskImportSchema();
|
|
1893
|
+
const contextLines = context.split("\n").length;
|
|
1894
|
+
const contextChars = context.length;
|
|
1895
|
+
console.log(muted(`Context: ${String(contextLines)} lines, ${String(contextChars)} chars`));
|
|
1896
|
+
const planDir = getPlanningDir(id);
|
|
1897
|
+
await mkdir2(planDir, { recursive: true });
|
|
1898
|
+
const ticketIds = new Set(sprint.tickets.map((t) => t.id));
|
|
1899
|
+
if (options.auto) {
|
|
1900
|
+
const prompt = buildAutoPrompt(context, schema);
|
|
1901
|
+
const spinner = createSpinner(`${providerName} is planning tasks...`);
|
|
1902
|
+
spinner.start();
|
|
1903
|
+
let output;
|
|
1904
|
+
try {
|
|
1905
|
+
output = await invokeAiAuto(prompt, selectedPaths, planDir);
|
|
1906
|
+
spinner.succeed(`${providerName} finished planning`);
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
spinner.fail(`${providerName} planning failed`);
|
|
1909
|
+
if (err instanceof Error) {
|
|
1910
|
+
showError(`Failed to invoke ${providerName}: ${err.message}`);
|
|
1911
|
+
showTip(`Make sure the ${providerName.toLowerCase()} CLI is installed and configured.`);
|
|
1912
|
+
log.newline();
|
|
1913
|
+
}
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const blockedReason = parsePlanningBlocked(output);
|
|
1917
|
+
if (blockedReason) {
|
|
1918
|
+
showWarning(`Planning blocked: ${blockedReason}`);
|
|
1919
|
+
log.newline();
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
console.log(muted("Parsing response..."));
|
|
1923
|
+
let parsedTasks;
|
|
1924
|
+
try {
|
|
1925
|
+
parsedTasks = parseTasksJson(output);
|
|
1926
|
+
} catch (err) {
|
|
1927
|
+
if (err instanceof Error) {
|
|
1928
|
+
showError(`Failed to parse ${providerName} output: ${err.message}`);
|
|
1929
|
+
log.dim("Raw output:");
|
|
1930
|
+
console.log(output);
|
|
1931
|
+
log.newline();
|
|
1932
|
+
}
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (parsedTasks.length === 0) {
|
|
1936
|
+
showWarning("No tasks generated.");
|
|
1937
|
+
log.newline();
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
showSuccess(`Generated ${String(parsedTasks.length)} task(s):`);
|
|
1941
|
+
log.newline();
|
|
1942
|
+
console.log(renderParsedTasksTable(parsedTasks));
|
|
1943
|
+
console.log("");
|
|
1944
|
+
const validationExistingTasks = isReplan ? [] : await getTasks(id);
|
|
1945
|
+
const validationErrors = validateImportTasks(parsedTasks, validationExistingTasks, ticketIds);
|
|
1946
|
+
if (validationErrors.length > 0) {
|
|
1947
|
+
showError("Validation failed");
|
|
1948
|
+
for (const err of validationErrors) {
|
|
1949
|
+
log.item(error(err));
|
|
1950
|
+
}
|
|
1951
|
+
log.newline();
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
showInfo("Importing tasks...");
|
|
1955
|
+
const imported = await importTasks(parsedTasks, id, isReplan ? { replace: true } : void 0);
|
|
1956
|
+
await reorderByDependencies(id);
|
|
1957
|
+
log.dim("Tasks reordered by dependencies.");
|
|
1958
|
+
terminalBell();
|
|
1959
|
+
showSuccess(`Imported ${String(imported)}/${String(parsedTasks.length)} tasks.`);
|
|
1960
|
+
log.newline();
|
|
1961
|
+
} else {
|
|
1962
|
+
const outputFile = join5(planDir, "tasks.json");
|
|
1963
|
+
const prompt = buildInteractivePrompt(context, outputFile, schema);
|
|
1964
|
+
showInfo(`Starting interactive ${providerName} session...`);
|
|
1965
|
+
console.log(
|
|
1966
|
+
muted(
|
|
1967
|
+
` Planning ${String(ticketsToProcess.length)} ticket(s) across ${String(ticketsByProject.size)} project(s)`
|
|
1968
|
+
)
|
|
1969
|
+
);
|
|
1970
|
+
console.log(muted(` Exploring: ${selectedPaths.join(", ")}`));
|
|
1971
|
+
console.log(muted(`
|
|
1972
|
+
${providerName} will read planning-context.md and explore the repos.`));
|
|
1973
|
+
console.log(muted(` When done, ask ${providerName} to write tasks to: ${outputFile}
|
|
1974
|
+
`));
|
|
1975
|
+
try {
|
|
1976
|
+
await invokeAiInteractive(prompt, selectedPaths, planDir);
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
if (err instanceof Error) {
|
|
1979
|
+
showError(`Failed to invoke ${providerName}: ${err.message}`);
|
|
1980
|
+
showTip(`Make sure the ${providerName.toLowerCase()} CLI is installed and configured.`);
|
|
1981
|
+
log.newline();
|
|
1982
|
+
}
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
console.log("");
|
|
1986
|
+
if (await fileExists(outputFile)) {
|
|
1987
|
+
showInfo("Task file found. Processing...");
|
|
1988
|
+
let content;
|
|
1989
|
+
try {
|
|
1990
|
+
content = await readFile3(outputFile, "utf-8");
|
|
1991
|
+
} catch {
|
|
1992
|
+
showError(`Failed to read task file: ${outputFile}`);
|
|
1993
|
+
log.newline();
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
let parsedTasks;
|
|
1997
|
+
try {
|
|
1998
|
+
parsedTasks = parseTasksJson(content);
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
if (err instanceof Error) {
|
|
2001
|
+
showError(`Failed to parse task file: ${err.message}`);
|
|
2002
|
+
log.newline();
|
|
2003
|
+
}
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (parsedTasks.length === 0) {
|
|
2007
|
+
showWarning("No tasks in file.");
|
|
2008
|
+
log.newline();
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
showSuccess(`Found ${String(parsedTasks.length)} task(s):`);
|
|
2012
|
+
log.newline();
|
|
2013
|
+
console.log(renderParsedTasksTable(parsedTasks));
|
|
2014
|
+
console.log("");
|
|
2015
|
+
const validationExistingTasks = isReplan ? [] : await getTasks(id);
|
|
2016
|
+
const validationErrors = validateImportTasks(parsedTasks, validationExistingTasks, ticketIds);
|
|
2017
|
+
if (validationErrors.length > 0) {
|
|
2018
|
+
showError("Validation failed");
|
|
2019
|
+
for (const err of validationErrors) {
|
|
2020
|
+
log.item(error(err));
|
|
2021
|
+
}
|
|
2022
|
+
log.newline();
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
showInfo("Importing tasks...");
|
|
2026
|
+
const imported = await importTasks(parsedTasks, id, isReplan ? { replace: true } : void 0);
|
|
2027
|
+
await reorderByDependencies(id);
|
|
2028
|
+
log.dim("Tasks reordered by dependencies.");
|
|
2029
|
+
terminalBell();
|
|
2030
|
+
showSuccess(`Imported ${String(imported)}/${String(parsedTasks.length)} tasks.`);
|
|
2031
|
+
log.newline();
|
|
2032
|
+
} else {
|
|
2033
|
+
showWarning("No task file found.");
|
|
2034
|
+
showTip(`Expected: ${outputFile}`);
|
|
2035
|
+
showNextStep("ralphctl sprint plan", "run planning again to create tasks");
|
|
2036
|
+
log.newline();
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// src/ai/runner.ts
|
|
2042
|
+
import { confirm as confirm5, input as input2, select as select2 } from "@inquirer/prompts";
|
|
2043
|
+
|
|
2044
|
+
// src/ai/executor.ts
|
|
2045
|
+
import { confirm as confirm4 } from "@inquirer/prompts";
|
|
2046
|
+
import { readFile as readFile4, unlink as unlink2 } from "fs/promises";
|
|
2047
|
+
|
|
2048
|
+
// src/ai/parser.ts
|
|
2049
|
+
function parseExecutionResult(output) {
|
|
2050
|
+
const verifiedMatch = /<task-verified>([\s\S]*?)<\/task-verified>/.exec(output);
|
|
2051
|
+
const verified = verifiedMatch !== null;
|
|
2052
|
+
const verificationOutput = verifiedMatch?.[1]?.trim();
|
|
2053
|
+
if (output.includes("<task-complete>")) {
|
|
2054
|
+
if (!verified) {
|
|
2055
|
+
return {
|
|
2056
|
+
success: false,
|
|
2057
|
+
output,
|
|
2058
|
+
blockedReason: "Task marked complete without verification. Output <task-verified> with verification results before <task-complete>."
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
return { success: true, output, verified, verificationOutput };
|
|
2062
|
+
}
|
|
2063
|
+
const blockedMatch = /<task-blocked>([\s\S]*?)<\/task-blocked>/.exec(output);
|
|
2064
|
+
if (blockedMatch) {
|
|
2065
|
+
return { success: false, output, blockedReason: blockedMatch[1]?.trim(), verified, verificationOutput };
|
|
2066
|
+
}
|
|
2067
|
+
return { success: false, output, blockedReason: "No completion signal received", verified, verificationOutput };
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// src/ai/rate-limiter.ts
|
|
2071
|
+
var RateLimitCoordinator = class {
|
|
2072
|
+
resumeAt = null;
|
|
2073
|
+
waiters = [];
|
|
2074
|
+
timer = null;
|
|
2075
|
+
onPauseCallback;
|
|
2076
|
+
onResumeCallback;
|
|
2077
|
+
constructor(options) {
|
|
2078
|
+
this.onPauseCallback = options?.onPause;
|
|
2079
|
+
this.onResumeCallback = options?.onResume;
|
|
2080
|
+
}
|
|
2081
|
+
/** Whether the coordinator is currently paused due to a rate limit. */
|
|
2082
|
+
get isPaused() {
|
|
2083
|
+
return this.resumeAt !== null && Date.now() < this.resumeAt;
|
|
2084
|
+
}
|
|
2085
|
+
/** Milliseconds remaining until resume, or 0 if not paused. */
|
|
2086
|
+
get remainingMs() {
|
|
2087
|
+
if (this.resumeAt === null) return 0;
|
|
2088
|
+
return Math.max(0, this.resumeAt - Date.now());
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Pause new task launches for a given duration.
|
|
2092
|
+
* If already paused, extends the pause if the new duration is longer.
|
|
2093
|
+
*/
|
|
2094
|
+
pause(delayMs) {
|
|
2095
|
+
const newResumeAt = Date.now() + delayMs;
|
|
2096
|
+
if (this.resumeAt !== null && newResumeAt <= this.resumeAt) {
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
this.resumeAt = newResumeAt;
|
|
2100
|
+
if (this.timer !== null) {
|
|
2101
|
+
clearTimeout(this.timer);
|
|
2102
|
+
}
|
|
2103
|
+
this.onPauseCallback?.(delayMs);
|
|
2104
|
+
this.timer = setTimeout(() => {
|
|
2105
|
+
this.resume();
|
|
2106
|
+
}, delayMs);
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Wait until the rate limit pause is lifted.
|
|
2110
|
+
* Returns immediately if not paused.
|
|
2111
|
+
*/
|
|
2112
|
+
async waitIfPaused() {
|
|
2113
|
+
if (!this.isPaused) return;
|
|
2114
|
+
return new Promise((resolve) => {
|
|
2115
|
+
this.waiters.push(resolve);
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Clean up timers. Call when execution is complete.
|
|
2120
|
+
*/
|
|
2121
|
+
dispose() {
|
|
2122
|
+
if (this.timer !== null) {
|
|
2123
|
+
clearTimeout(this.timer);
|
|
2124
|
+
this.timer = null;
|
|
2125
|
+
}
|
|
2126
|
+
this.resume();
|
|
2127
|
+
}
|
|
2128
|
+
resume() {
|
|
2129
|
+
this.resumeAt = null;
|
|
2130
|
+
this.timer = null;
|
|
2131
|
+
this.onResumeCallback?.();
|
|
2132
|
+
const waiters = this.waiters;
|
|
2133
|
+
this.waiters = [];
|
|
2134
|
+
for (const resolve of waiters) {
|
|
2135
|
+
resolve();
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
};
|
|
2139
|
+
|
|
2140
|
+
// src/ai/task-context.ts
|
|
2141
|
+
import { execSync } from "child_process";
|
|
2142
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
2143
|
+
import { join as join7 } from "path";
|
|
2144
|
+
|
|
2145
|
+
// src/ai/permissions.ts
|
|
2146
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
2147
|
+
import { join as join6 } from "path";
|
|
2148
|
+
import { homedir } from "os";
|
|
2149
|
+
function getProviderPermissions(projectPath, provider) {
|
|
2150
|
+
const permissions = {
|
|
2151
|
+
allow: [],
|
|
2152
|
+
deny: []
|
|
2153
|
+
};
|
|
2154
|
+
if (provider === "copilot") {
|
|
2155
|
+
return permissions;
|
|
2156
|
+
}
|
|
2157
|
+
const projectSettingsPath = join6(projectPath, ".claude", "settings.local.json");
|
|
2158
|
+
if (existsSync2(projectSettingsPath)) {
|
|
2159
|
+
try {
|
|
2160
|
+
const content = readFileSync2(projectSettingsPath, "utf-8");
|
|
2161
|
+
const settings = JSON.parse(content);
|
|
2162
|
+
if (settings.permissions?.allow) {
|
|
2163
|
+
permissions.allow.push(...settings.permissions.allow);
|
|
2164
|
+
}
|
|
2165
|
+
if (settings.permissions?.deny) {
|
|
2166
|
+
permissions.deny.push(...settings.permissions.deny);
|
|
2167
|
+
}
|
|
2168
|
+
} catch {
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
const userSettingsPath = join6(homedir(), ".claude", "settings.json");
|
|
2172
|
+
if (existsSync2(userSettingsPath)) {
|
|
2173
|
+
try {
|
|
2174
|
+
const content = readFileSync2(userSettingsPath, "utf-8");
|
|
2175
|
+
const settings = JSON.parse(content);
|
|
2176
|
+
if (settings.permissions?.allow) {
|
|
2177
|
+
permissions.allow.push(...settings.permissions.allow);
|
|
2178
|
+
}
|
|
2179
|
+
if (settings.permissions?.deny) {
|
|
2180
|
+
permissions.deny.push(...settings.permissions.deny);
|
|
2181
|
+
}
|
|
2182
|
+
} catch {
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return permissions;
|
|
2186
|
+
}
|
|
2187
|
+
function isToolAllowed(permissions, tool, specifier) {
|
|
2188
|
+
for (const pattern of permissions.deny) {
|
|
2189
|
+
if (matchesPattern(pattern, tool, specifier)) {
|
|
2190
|
+
return false;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
for (const pattern of permissions.allow) {
|
|
2194
|
+
if (matchesPattern(pattern, tool, specifier)) {
|
|
2195
|
+
return true;
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
return "ask";
|
|
2199
|
+
}
|
|
2200
|
+
function matchesPattern(pattern, tool, specifier) {
|
|
2201
|
+
const parenIdx = pattern.indexOf("(");
|
|
2202
|
+
if (parenIdx === -1) {
|
|
2203
|
+
return pattern === tool;
|
|
2204
|
+
}
|
|
2205
|
+
const patternTool = pattern.slice(0, parenIdx);
|
|
2206
|
+
if (patternTool !== tool) {
|
|
2207
|
+
return false;
|
|
2208
|
+
}
|
|
2209
|
+
const specPattern = pattern.slice(parenIdx + 1, -1);
|
|
2210
|
+
if (specPattern === "*") {
|
|
2211
|
+
return true;
|
|
2212
|
+
}
|
|
2213
|
+
if (!specifier) {
|
|
2214
|
+
return false;
|
|
2215
|
+
}
|
|
2216
|
+
if (specPattern.endsWith(":*")) {
|
|
2217
|
+
const prefix = specPattern.slice(0, -2);
|
|
2218
|
+
return specifier.startsWith(prefix);
|
|
2219
|
+
}
|
|
2220
|
+
if (specPattern.endsWith("*")) {
|
|
2221
|
+
const prefix = specPattern.slice(0, -1);
|
|
2222
|
+
return specifier.startsWith(prefix);
|
|
2223
|
+
}
|
|
2224
|
+
return specPattern === specifier;
|
|
2225
|
+
}
|
|
2226
|
+
function checkTaskPermissions(projectPath, options) {
|
|
2227
|
+
const warnings = [];
|
|
2228
|
+
const permissions = getProviderPermissions(projectPath, options.provider);
|
|
2229
|
+
if (options.needsCommit !== false) {
|
|
2230
|
+
const commitAllowed = isToolAllowed(permissions, "Bash", "git commit");
|
|
2231
|
+
if (commitAllowed !== true) {
|
|
2232
|
+
warnings.push({
|
|
2233
|
+
tool: "Bash",
|
|
2234
|
+
specifier: "git commit",
|
|
2235
|
+
message: "Git commits may require manual approval"
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
if (options.checkScript) {
|
|
2240
|
+
const checkAllowed = isToolAllowed(permissions, "Bash", options.checkScript);
|
|
2241
|
+
if (checkAllowed !== true) {
|
|
2242
|
+
warnings.push({
|
|
2243
|
+
tool: "Bash",
|
|
2244
|
+
specifier: options.checkScript,
|
|
2245
|
+
message: `Check script "${options.checkScript}" may require approval`
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
return warnings;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// src/ai/task-context.ts
|
|
2253
|
+
function getRecentGitHistory(projectPath, count = 20) {
|
|
2254
|
+
try {
|
|
2255
|
+
assertSafeCwd(projectPath);
|
|
2256
|
+
const result = execSync(`git log -${String(count)} --oneline --no-decorate`, {
|
|
2257
|
+
cwd: projectPath,
|
|
2258
|
+
encoding: "utf-8",
|
|
2259
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2260
|
+
});
|
|
2261
|
+
return result.trim();
|
|
2262
|
+
} catch {
|
|
2263
|
+
return "(Unable to retrieve git history)";
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
function getEffectiveCheckScript(project, projectPath) {
|
|
2267
|
+
if (project) {
|
|
2268
|
+
const repo = project.repositories.find((r) => r.path === projectPath);
|
|
2269
|
+
if (repo?.checkScript) {
|
|
2270
|
+
return repo.checkScript;
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
return null;
|
|
2274
|
+
}
|
|
2275
|
+
function formatTask(ctx) {
|
|
2276
|
+
const lines = [];
|
|
2277
|
+
lines.push("## Task Directive");
|
|
2278
|
+
lines.push("");
|
|
2279
|
+
lines.push(`**Task:** ${ctx.task.name}`);
|
|
2280
|
+
lines.push(`**ID:** ${ctx.task.id}`);
|
|
2281
|
+
lines.push(`**Project:** ${ctx.task.projectPath}`);
|
|
2282
|
+
lines.push("");
|
|
2283
|
+
lines.push("**ONE TASK ONLY.** Complete THIS task and nothing else. Do not continue to other tasks.");
|
|
2284
|
+
if (ctx.task.description) {
|
|
2285
|
+
lines.push("");
|
|
2286
|
+
lines.push(ctx.task.description);
|
|
2287
|
+
}
|
|
2288
|
+
if (ctx.task.steps.length > 0) {
|
|
2289
|
+
lines.push("");
|
|
2290
|
+
lines.push("## Implementation Steps");
|
|
2291
|
+
lines.push("");
|
|
2292
|
+
lines.push("Follow these steps precisely and in order:");
|
|
2293
|
+
lines.push("");
|
|
2294
|
+
ctx.task.steps.forEach((step, i) => {
|
|
2295
|
+
lines.push(`${String(i + 1)}. ${step}`);
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
return lines.join("\n");
|
|
2299
|
+
}
|
|
2300
|
+
function buildFullTaskContext(ctx, progressSummary, gitHistory, checkScript, checkStatus) {
|
|
2301
|
+
const lines = [];
|
|
2302
|
+
lines.push(formatTask(ctx));
|
|
2303
|
+
if (ctx.sprint.branch) {
|
|
2304
|
+
lines.push("");
|
|
2305
|
+
lines.push("## Branch");
|
|
2306
|
+
lines.push("");
|
|
2307
|
+
lines.push(
|
|
2308
|
+
`You are working on branch \`${ctx.sprint.branch}\`. All commits go to this branch. Do not switch branches.`
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
lines.push("");
|
|
2312
|
+
lines.push("## Check Script");
|
|
2313
|
+
lines.push("");
|
|
2314
|
+
if (checkScript) {
|
|
2315
|
+
lines.push("The harness runs this command at sprint start and after every task as a post-task gate:");
|
|
2316
|
+
lines.push("");
|
|
2317
|
+
lines.push("```bash");
|
|
2318
|
+
lines.push(checkScript);
|
|
2319
|
+
lines.push("```");
|
|
2320
|
+
lines.push("");
|
|
2321
|
+
lines.push("Your task is NOT marked done unless this command passes after completion.");
|
|
2322
|
+
} else {
|
|
2323
|
+
lines.push("No check script is configured. Read CLAUDE.md in the project root to find verification commands.");
|
|
2324
|
+
}
|
|
2325
|
+
if (checkStatus) {
|
|
2326
|
+
lines.push("");
|
|
2327
|
+
lines.push("## Environment Status");
|
|
2328
|
+
lines.push("");
|
|
2329
|
+
if (checkStatus.ran) {
|
|
2330
|
+
lines.push("The check script ran successfully at sprint start. Dependencies are current.");
|
|
2331
|
+
lines.push("Do not re-run the install portion unless you encounter dependency errors.");
|
|
2332
|
+
} else {
|
|
2333
|
+
lines.push(
|
|
2334
|
+
"No check script is configured for this repository. Read CLAUDE.md or project configuration files (package.json, pyproject.toml, etc.) to discover build, test, and lint commands."
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
lines.push("");
|
|
2339
|
+
lines.push("---");
|
|
2340
|
+
lines.push("");
|
|
2341
|
+
if (progressSummary) {
|
|
2342
|
+
lines.push("## Prior Task Learnings");
|
|
2343
|
+
lines.push("");
|
|
2344
|
+
lines.push("_Reference \u2014 consult when relevant to your implementation._");
|
|
2345
|
+
lines.push("");
|
|
2346
|
+
lines.push(progressSummary);
|
|
2347
|
+
lines.push("");
|
|
2348
|
+
}
|
|
2349
|
+
if (ctx.task.ticketId) {
|
|
2350
|
+
const ticket = ctx.sprint.tickets.find((t) => t.id === ctx.task.ticketId);
|
|
2351
|
+
if (ticket?.requirements) {
|
|
2352
|
+
lines.push("## Ticket Requirements");
|
|
2353
|
+
lines.push("");
|
|
2354
|
+
lines.push(
|
|
2355
|
+
"_Reference \u2014 these describe the full ticket scope. This task implements a specific part. Use to validate your work and understand constraints, but follow the Implementation Steps above. Do not expand scope beyond declared steps._"
|
|
2356
|
+
);
|
|
2357
|
+
lines.push("");
|
|
2358
|
+
lines.push(ticket.requirements);
|
|
2359
|
+
lines.push("");
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
lines.push("## Git History (recent commits)");
|
|
2363
|
+
lines.push("");
|
|
2364
|
+
lines.push("```");
|
|
2365
|
+
lines.push(gitHistory);
|
|
2366
|
+
lines.push("```");
|
|
2367
|
+
return lines.join("\n");
|
|
2368
|
+
}
|
|
2369
|
+
function getContextFileName(sprintId, taskId) {
|
|
2370
|
+
return `.ralphctl-sprint-${sprintId}-task-${taskId}-context.md`;
|
|
2371
|
+
}
|
|
2372
|
+
async function writeTaskContextFile(projectPath, taskContent, instructions, sprintId, taskId) {
|
|
2373
|
+
const contextFile = join7(projectPath, getContextFileName(sprintId, taskId));
|
|
2374
|
+
const warning2 = `<!-- TEMPORARY FILE - DO NOT COMMIT -->
|
|
2375
|
+
<!-- This file is auto-generated by ralphctl for task execution context -->
|
|
2376
|
+
<!-- It will be automatically cleaned up after task completion -->
|
|
2377
|
+
|
|
2378
|
+
`;
|
|
2379
|
+
const fullContent = `${warning2}${taskContent}
|
|
2380
|
+
|
|
2381
|
+
---
|
|
2382
|
+
|
|
2383
|
+
## Instructions
|
|
2384
|
+
|
|
2385
|
+
${instructions}`;
|
|
2386
|
+
await writeFile4(contextFile, fullContent, { encoding: "utf-8", mode: 384 });
|
|
2387
|
+
return contextFile;
|
|
2388
|
+
}
|
|
2389
|
+
async function getProjectForTask(task, sprint) {
|
|
2390
|
+
if (!task.ticketId) return void 0;
|
|
2391
|
+
const ticket = sprint.tickets.find((t) => t.id === task.ticketId);
|
|
2392
|
+
if (!ticket) return void 0;
|
|
2393
|
+
try {
|
|
2394
|
+
return await getProject(ticket.projectName);
|
|
2395
|
+
} catch (err) {
|
|
2396
|
+
if (err instanceof ProjectNotFoundError) {
|
|
2397
|
+
return void 0;
|
|
2398
|
+
}
|
|
2399
|
+
throw err;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
function runPermissionCheck(ctx, noCommit, provider) {
|
|
2403
|
+
const checkScript = getEffectiveCheckScript(ctx.project, ctx.task.projectPath);
|
|
2404
|
+
const warnings = checkTaskPermissions(ctx.task.projectPath, {
|
|
2405
|
+
checkScript,
|
|
2406
|
+
needsCommit: !noCommit,
|
|
2407
|
+
provider
|
|
2408
|
+
});
|
|
2409
|
+
if (warnings.length > 0) {
|
|
2410
|
+
console.log(warning("\n Permission warnings:"));
|
|
2411
|
+
for (const w of warnings) {
|
|
2412
|
+
console.log(muted(` - ${w.message}`));
|
|
2413
|
+
}
|
|
2414
|
+
console.log(muted(" Consider adjusting tool permissions for your AI provider\n"));
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// src/ai/lifecycle.ts
|
|
2419
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2420
|
+
var DEFAULT_HOOK_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2421
|
+
function getHookTimeoutMs() {
|
|
2422
|
+
const envVal = process.env["RALPHCTL_SETUP_TIMEOUT_MS"];
|
|
2423
|
+
if (envVal) {
|
|
2424
|
+
const parsed = Number(envVal);
|
|
2425
|
+
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
|
2426
|
+
}
|
|
2427
|
+
return DEFAULT_HOOK_TIMEOUT_MS;
|
|
2428
|
+
}
|
|
2429
|
+
function runLifecycleHook(projectPath, script, event) {
|
|
2430
|
+
assertSafeCwd(projectPath);
|
|
2431
|
+
const timeoutMs = getHookTimeoutMs();
|
|
2432
|
+
const result = spawnSync2(script, {
|
|
2433
|
+
cwd: projectPath,
|
|
2434
|
+
shell: true,
|
|
2435
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2436
|
+
encoding: "utf-8",
|
|
2437
|
+
timeout: timeoutMs,
|
|
2438
|
+
env: { ...process.env, RALPHCTL_LIFECYCLE_EVENT: event }
|
|
2439
|
+
});
|
|
2440
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
2441
|
+
return { passed: result.status === 0, output };
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// src/ai/executor.ts
|
|
2445
|
+
async function executeTask(ctx, options, sprintId, resumeSessionId, provider, checkStatus) {
|
|
2446
|
+
const p = provider ?? await getActiveProvider();
|
|
2447
|
+
const label = p.displayName;
|
|
2448
|
+
const projectPath = ctx.task.projectPath;
|
|
2449
|
+
const sprintDir = getSprintDir(sprintId);
|
|
2450
|
+
if (options.session) {
|
|
2451
|
+
const contextFileName = getContextFileName(sprintId, ctx.task.id);
|
|
2452
|
+
const gitHistory = getRecentGitHistory(projectPath, 20);
|
|
2453
|
+
const checkScript = getEffectiveCheckScript(ctx.project, projectPath);
|
|
2454
|
+
const allProgress = await getProgress(sprintId);
|
|
2455
|
+
const progressSummary = summarizeProgressForContext(allProgress, projectPath, 3);
|
|
2456
|
+
const fullTaskContent = buildFullTaskContext(ctx, progressSummary || null, gitHistory, checkScript, checkStatus);
|
|
2457
|
+
const progressFilePath = getProgressFilePath(sprintId);
|
|
2458
|
+
const instructions = buildTaskExecutionPrompt(progressFilePath, options.noCommit, contextFileName);
|
|
2459
|
+
const contextFile = await writeTaskContextFile(projectPath, fullTaskContent, instructions, sprintId, ctx.task.id);
|
|
2460
|
+
try {
|
|
2461
|
+
const result = spawnInteractive(
|
|
2462
|
+
`Read ${contextFileName} and follow the instructions`,
|
|
2463
|
+
{
|
|
2464
|
+
cwd: projectPath,
|
|
2465
|
+
args: ["--add-dir", sprintDir]
|
|
2466
|
+
},
|
|
2467
|
+
p
|
|
2468
|
+
);
|
|
2469
|
+
if (result.error) {
|
|
2470
|
+
return { success: false, output: "", blockedReason: result.error, sessionId: null };
|
|
2471
|
+
}
|
|
2472
|
+
if (result.code === 0) {
|
|
2473
|
+
return { success: true, output: "", verified: true, sessionId: null };
|
|
2474
|
+
}
|
|
2475
|
+
return {
|
|
2476
|
+
success: false,
|
|
2477
|
+
output: "",
|
|
2478
|
+
blockedReason: `${label} exited with code ${String(result.code)}`,
|
|
2479
|
+
sessionId: null
|
|
2480
|
+
};
|
|
2481
|
+
} finally {
|
|
2482
|
+
try {
|
|
2483
|
+
await unlink2(contextFile);
|
|
2484
|
+
} catch {
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
let spawnResult;
|
|
2489
|
+
if (resumeSessionId) {
|
|
2490
|
+
const spinner = createSpinner(`Resuming ${label} session for: ${ctx.task.name}`).start();
|
|
2491
|
+
const manager = ProcessManager.getInstance();
|
|
2492
|
+
const deregister = manager.registerCleanup(() => {
|
|
2493
|
+
spinner.stop();
|
|
2494
|
+
});
|
|
2495
|
+
try {
|
|
2496
|
+
spawnResult = await spawnWithRetry(
|
|
2497
|
+
{
|
|
2498
|
+
cwd: projectPath,
|
|
2499
|
+
args: ["--add-dir", sprintDir],
|
|
2500
|
+
prompt: "Continue where you left off. Complete the task and signal completion.",
|
|
2501
|
+
resumeSessionId
|
|
2502
|
+
},
|
|
2503
|
+
{
|
|
2504
|
+
maxRetries: options.maxRetries,
|
|
2505
|
+
onRetry: (attempt, delayMs) => {
|
|
2506
|
+
spinner.text = `Rate limited, retrying in ${String(Math.round(delayMs / 1e3))}s (attempt ${String(attempt)})...`;
|
|
2507
|
+
}
|
|
2508
|
+
},
|
|
2509
|
+
p
|
|
2510
|
+
);
|
|
2511
|
+
spinner.succeed(`${label} completed: ${ctx.task.name}`);
|
|
2512
|
+
} catch (err) {
|
|
2513
|
+
spinner.fail(`${label} failed: ${ctx.task.name}`);
|
|
2514
|
+
throw err;
|
|
2515
|
+
} finally {
|
|
2516
|
+
deregister();
|
|
2517
|
+
}
|
|
2518
|
+
} else {
|
|
2519
|
+
const contextFileName = getContextFileName(sprintId, ctx.task.id);
|
|
2520
|
+
const gitHistory = getRecentGitHistory(projectPath, 20);
|
|
2521
|
+
const checkScript = getEffectiveCheckScript(ctx.project, projectPath);
|
|
2522
|
+
const allProgress = await getProgress(sprintId);
|
|
2523
|
+
const progressSummary = summarizeProgressForContext(allProgress, projectPath, 3);
|
|
2524
|
+
const fullTaskContent = buildFullTaskContext(ctx, progressSummary || null, gitHistory, checkScript, checkStatus);
|
|
2525
|
+
const progressFilePath = getProgressFilePath(sprintId);
|
|
2526
|
+
const instructions = buildTaskExecutionPrompt(progressFilePath, options.noCommit, contextFileName);
|
|
2527
|
+
const contextFile = await writeTaskContextFile(projectPath, fullTaskContent, instructions, sprintId, ctx.task.id);
|
|
2528
|
+
const spinner = createSpinner(`${label} is working on: ${ctx.task.name}`).start();
|
|
2529
|
+
const manager = ProcessManager.getInstance();
|
|
2530
|
+
const deregister = manager.registerCleanup(() => {
|
|
2531
|
+
spinner.stop();
|
|
2532
|
+
});
|
|
2533
|
+
try {
|
|
2534
|
+
const contextContent = await readFile4(contextFile, "utf-8");
|
|
2535
|
+
spawnResult = await spawnWithRetry(
|
|
2536
|
+
{
|
|
2537
|
+
cwd: projectPath,
|
|
2538
|
+
args: ["--add-dir", sprintDir],
|
|
2539
|
+
prompt: contextContent
|
|
2540
|
+
},
|
|
2541
|
+
{
|
|
2542
|
+
maxRetries: options.maxRetries,
|
|
2543
|
+
onRetry: (attempt, delayMs) => {
|
|
2544
|
+
spinner.text = `Rate limited, retrying in ${String(Math.round(delayMs / 1e3))}s (attempt ${String(attempt)})...`;
|
|
2545
|
+
}
|
|
2546
|
+
},
|
|
2547
|
+
p
|
|
2548
|
+
);
|
|
2549
|
+
spinner.succeed(`${label} completed: ${ctx.task.name}`);
|
|
2550
|
+
} catch (err) {
|
|
2551
|
+
spinner.fail(`${label} failed: ${ctx.task.name}`);
|
|
2552
|
+
throw err;
|
|
2553
|
+
} finally {
|
|
2554
|
+
deregister();
|
|
2555
|
+
try {
|
|
2556
|
+
await unlink2(contextFile);
|
|
2557
|
+
} catch {
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
const parsed = parseExecutionResult(spawnResult.stdout);
|
|
2562
|
+
return { ...parsed, sessionId: spawnResult.sessionId };
|
|
2563
|
+
}
|
|
2564
|
+
async function areAllRemainingBlocked(sprintId) {
|
|
2565
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
2566
|
+
if (remaining.length === 0) return false;
|
|
2567
|
+
for (const task of remaining) {
|
|
2568
|
+
if (task.status === "in_progress") return false;
|
|
2569
|
+
const blocked = await isTaskBlocked(task.id, sprintId);
|
|
2570
|
+
if (!blocked) return false;
|
|
2571
|
+
}
|
|
2572
|
+
return true;
|
|
2573
|
+
}
|
|
2574
|
+
async function executeTaskLoop(sprintId, options, checkResults) {
|
|
2575
|
+
ProcessManager.getInstance().ensureHandlers();
|
|
2576
|
+
const provider = await getActiveProvider();
|
|
2577
|
+
const label = provider.displayName;
|
|
2578
|
+
const sprint = await getSprint(sprintId);
|
|
2579
|
+
let completedCount = 0;
|
|
2580
|
+
const targetCount = options.count ?? Infinity;
|
|
2581
|
+
const firstTask = await getNextTask(sprintId);
|
|
2582
|
+
if (firstTask?.status === "in_progress") {
|
|
2583
|
+
console.log(warning(`
|
|
2584
|
+
Resuming from: ${firstTask.id} - ${firstTask.name}`));
|
|
2585
|
+
}
|
|
2586
|
+
while (completedCount < targetCount) {
|
|
2587
|
+
const manager = ProcessManager.getInstance();
|
|
2588
|
+
if (manager.isShuttingDown()) {
|
|
2589
|
+
const remaining2 = await getRemainingTasks(sprintId);
|
|
2590
|
+
return {
|
|
2591
|
+
completed: completedCount,
|
|
2592
|
+
remaining: remaining2.length,
|
|
2593
|
+
stopReason: "task_blocked",
|
|
2594
|
+
blockedTask: null,
|
|
2595
|
+
blockedReason: "Interrupted by user",
|
|
2596
|
+
exitCode: EXIT_ERROR
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
const task = await getNextTask(sprintId);
|
|
2600
|
+
if (!task) {
|
|
2601
|
+
if (await areAllRemainingBlocked(sprintId)) {
|
|
2602
|
+
const remaining3 = await getRemainingTasks(sprintId);
|
|
2603
|
+
return {
|
|
2604
|
+
completed: completedCount,
|
|
2605
|
+
remaining: remaining3.length,
|
|
2606
|
+
stopReason: "all_blocked",
|
|
2607
|
+
blockedTask: null,
|
|
2608
|
+
blockedReason: "All remaining tasks are blocked by dependencies",
|
|
2609
|
+
exitCode: EXIT_ALL_BLOCKED
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
const remaining2 = await getRemainingTasks(sprintId);
|
|
2613
|
+
if (remaining2.length === 0 && completedCount === 0) {
|
|
2614
|
+
return {
|
|
2615
|
+
completed: 0,
|
|
2616
|
+
remaining: 0,
|
|
2617
|
+
stopReason: "no_tasks",
|
|
2618
|
+
blockedTask: null,
|
|
2619
|
+
blockedReason: null,
|
|
2620
|
+
exitCode: EXIT_NO_TASKS
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
console.log(success("\nAll tasks completed!"));
|
|
2624
|
+
return {
|
|
2625
|
+
completed: completedCount,
|
|
2626
|
+
remaining: 0,
|
|
2627
|
+
stopReason: "all_completed",
|
|
2628
|
+
blockedTask: null,
|
|
2629
|
+
blockedReason: null,
|
|
2630
|
+
exitCode: EXIT_SUCCESS
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
console.log(info(`
|
|
2634
|
+
--- Task ${String(task.order)}: ${task.name} ---`));
|
|
2635
|
+
console.log(info("ID: ") + task.id);
|
|
2636
|
+
console.log(info("Project: ") + task.projectPath);
|
|
2637
|
+
console.log(info("Status: ") + formatTaskStatus(task.status));
|
|
2638
|
+
if (task.status !== "in_progress") {
|
|
2639
|
+
await updateTaskStatus(task.id, "in_progress", sprintId);
|
|
2640
|
+
console.log(muted("Status updated to: in_progress"));
|
|
2641
|
+
}
|
|
2642
|
+
const project = await getProjectForTask(task, sprint);
|
|
2643
|
+
const ctx = { sprint, task, project };
|
|
2644
|
+
const taskPrompt = formatTask(ctx);
|
|
2645
|
+
if (completedCount === 0) {
|
|
2646
|
+
runPermissionCheck(ctx, options.noCommit, provider.name);
|
|
2647
|
+
}
|
|
2648
|
+
if (sprint.branch) {
|
|
2649
|
+
if (!verifySprintBranch(task.projectPath, sprint.branch)) {
|
|
2650
|
+
console.log(warning(`
|
|
2651
|
+
Branch verification failed: expected '${sprint.branch}' in ${task.projectPath}`));
|
|
2652
|
+
console.log(muted(`Task ${task.id} remains in_progress.`));
|
|
2653
|
+
const remaining2 = await getRemainingTasks(sprintId);
|
|
2654
|
+
return {
|
|
2655
|
+
completed: completedCount,
|
|
2656
|
+
remaining: remaining2.length,
|
|
2657
|
+
stopReason: "task_blocked",
|
|
2658
|
+
blockedTask: task,
|
|
2659
|
+
blockedReason: `Repository ${task.projectPath} is not on expected branch '${sprint.branch}'`,
|
|
2660
|
+
exitCode: EXIT_ERROR
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
if (options.session) {
|
|
2665
|
+
console.log(highlight(`
|
|
2666
|
+
[Task Context for ${label}]`));
|
|
2667
|
+
console.log(muted("\u2500".repeat(50)));
|
|
2668
|
+
console.log(taskPrompt);
|
|
2669
|
+
console.log(muted("\u2500".repeat(50)));
|
|
2670
|
+
console.log(muted(`
|
|
2671
|
+
Starting ${label} in ${task.projectPath} (session)...
|
|
2672
|
+
`));
|
|
2673
|
+
} else {
|
|
2674
|
+
console.log(muted(`Starting ${label} in ${task.projectPath} (headless)...`));
|
|
2675
|
+
}
|
|
2676
|
+
const result = await executeTask(ctx, options, sprintId, void 0, provider, checkResults?.get(task.projectPath));
|
|
2677
|
+
if (!result.success) {
|
|
2678
|
+
console.log(warning("\nTask not completed."));
|
|
2679
|
+
if (result.blockedReason) {
|
|
2680
|
+
console.log(warning(`Reason: ${result.blockedReason}`));
|
|
2681
|
+
}
|
|
2682
|
+
console.log(muted("\nExecution paused. Task remains in_progress."));
|
|
2683
|
+
console.log(muted(`Resume with: ralphctl sprint start ${sprintId}
|
|
2684
|
+
`));
|
|
2685
|
+
const remaining2 = await getRemainingTasks(sprintId);
|
|
2686
|
+
return {
|
|
2687
|
+
completed: completedCount,
|
|
2688
|
+
remaining: remaining2.length,
|
|
2689
|
+
stopReason: "task_blocked",
|
|
2690
|
+
blockedTask: task,
|
|
2691
|
+
blockedReason: result.blockedReason ?? "Unknown reason",
|
|
2692
|
+
exitCode: EXIT_ERROR
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
if (result.verified) {
|
|
2696
|
+
await updateTask(
|
|
2697
|
+
task.id,
|
|
2698
|
+
{
|
|
2699
|
+
verified: true,
|
|
2700
|
+
verificationOutput: result.verificationOutput
|
|
2701
|
+
},
|
|
2702
|
+
sprintId
|
|
2703
|
+
);
|
|
2704
|
+
console.log(success("Verification: passed"));
|
|
2705
|
+
}
|
|
2706
|
+
const checkScript = getEffectiveCheckScript(project, task.projectPath);
|
|
2707
|
+
if (checkScript) {
|
|
2708
|
+
console.log(muted(`Running post-task check: ${checkScript}`));
|
|
2709
|
+
const hookResult = runLifecycleHook(task.projectPath, checkScript, "taskComplete");
|
|
2710
|
+
if (!hookResult.passed) {
|
|
2711
|
+
console.log(warning(`
|
|
2712
|
+
Post-task check failed for: ${task.name}`));
|
|
2713
|
+
console.log(muted("Task remains in_progress. Execution paused."));
|
|
2714
|
+
console.log(muted(`Resume with: ralphctl sprint start ${sprintId}
|
|
2715
|
+
`));
|
|
2716
|
+
const remaining2 = await getRemainingTasks(sprintId);
|
|
2717
|
+
return {
|
|
2718
|
+
completed: completedCount,
|
|
2719
|
+
remaining: remaining2.length,
|
|
2720
|
+
stopReason: "task_blocked",
|
|
2721
|
+
blockedTask: task,
|
|
2722
|
+
blockedReason: `Post-task check failed: ${hookResult.output.slice(0, 500)}`,
|
|
2723
|
+
exitCode: EXIT_ERROR
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
console.log(success("Post-task check: passed"));
|
|
2727
|
+
}
|
|
2728
|
+
await updateTaskStatus(task.id, "done", sprintId);
|
|
2729
|
+
console.log(success("Status updated to: done"));
|
|
2730
|
+
await logProgress(
|
|
2731
|
+
`Completed task: ${task.id} - ${task.name}
|
|
2732
|
+
|
|
2733
|
+
` + (task.description ? `Description: ${task.description}
|
|
2734
|
+
` : "") + (task.steps.length > 0 ? `Steps:
|
|
2735
|
+
${task.steps.map((s, i) => ` ${String(i + 1)}. ${s}`).join("\n")}` : ""),
|
|
2736
|
+
{ sprintId, projectPath: task.projectPath }
|
|
2737
|
+
);
|
|
2738
|
+
completedCount++;
|
|
2739
|
+
if (options.step && completedCount < targetCount) {
|
|
2740
|
+
const remaining2 = await getRemainingTasks(sprintId);
|
|
2741
|
+
if (remaining2.length > 0) {
|
|
2742
|
+
console.log(info(`
|
|
2743
|
+
${String(remaining2.length)} task(s) remaining.`));
|
|
2744
|
+
const continueLoop = await confirm4({
|
|
2745
|
+
message: "Continue to next task?",
|
|
2746
|
+
default: true
|
|
2747
|
+
});
|
|
2748
|
+
if (!continueLoop) {
|
|
2749
|
+
console.log(muted("\nExecution paused."));
|
|
2750
|
+
console.log(muted(`Resume with: ralphctl sprint start ${sprintId}
|
|
2751
|
+
`));
|
|
2752
|
+
return {
|
|
2753
|
+
completed: completedCount,
|
|
2754
|
+
remaining: remaining2.length,
|
|
2755
|
+
stopReason: "user_paused",
|
|
2756
|
+
blockedTask: null,
|
|
2757
|
+
blockedReason: null,
|
|
2758
|
+
exitCode: EXIT_SUCCESS
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
2765
|
+
return {
|
|
2766
|
+
completed: completedCount,
|
|
2767
|
+
remaining: remaining.length,
|
|
2768
|
+
stopReason: remaining.length === 0 ? "all_completed" : "count_reached",
|
|
2769
|
+
blockedTask: null,
|
|
2770
|
+
blockedReason: null,
|
|
2771
|
+
exitCode: EXIT_SUCCESS
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
function pickTasksToLaunch(readyTasks, inFlightPaths, concurrencyLimit, currentInFlight) {
|
|
2775
|
+
const available = readyTasks.filter((t) => !inFlightPaths.has(t.projectPath));
|
|
2776
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2777
|
+
for (const task of available) {
|
|
2778
|
+
if (!byPath.has(task.projectPath)) {
|
|
2779
|
+
byPath.set(task.projectPath, task);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
const candidates = [...byPath.values()];
|
|
2783
|
+
const slotsAvailable = concurrencyLimit - currentInFlight;
|
|
2784
|
+
return candidates.slice(0, Math.max(0, slotsAvailable));
|
|
2785
|
+
}
|
|
2786
|
+
async function executeTaskLoopParallel(sprintId, options, checkResults) {
|
|
2787
|
+
ProcessManager.getInstance().ensureHandlers();
|
|
2788
|
+
const provider = await getActiveProvider();
|
|
2789
|
+
const label = provider.displayName;
|
|
2790
|
+
const sprint = await getSprint(sprintId);
|
|
2791
|
+
let completedCount = 0;
|
|
2792
|
+
const targetCount = options.count ?? Infinity;
|
|
2793
|
+
const failFast = options.failFast ?? true;
|
|
2794
|
+
let hasFailed = false;
|
|
2795
|
+
let firstBlockedTask = null;
|
|
2796
|
+
let firstBlockedReason = null;
|
|
2797
|
+
const MAX_CONCURRENCY = 10;
|
|
2798
|
+
const allTasks = await getTasks(sprintId);
|
|
2799
|
+
const uniqueRepoPaths = new Set(allTasks.map((t) => t.projectPath));
|
|
2800
|
+
const concurrencyLimit = Math.min(options.concurrency ?? uniqueRepoPaths.size, MAX_CONCURRENCY);
|
|
2801
|
+
console.log(muted(`Parallel mode: up to ${String(concurrencyLimit)} concurrent task(s)`));
|
|
2802
|
+
const coordinator = new RateLimitCoordinator({
|
|
2803
|
+
onPause: (delayMs) => {
|
|
2804
|
+
console.log(warning(`
|
|
2805
|
+
Rate limited. Pausing new launches for ${String(Math.round(delayMs / 1e3))}s...`));
|
|
2806
|
+
},
|
|
2807
|
+
onResume: () => {
|
|
2808
|
+
console.log(success("Rate limit cooldown ended. Resuming launches."));
|
|
2809
|
+
}
|
|
2810
|
+
});
|
|
2811
|
+
const inFlightPaths = /* @__PURE__ */ new Set();
|
|
2812
|
+
const running = /* @__PURE__ */ new Map();
|
|
2813
|
+
const taskSessionIds = /* @__PURE__ */ new Map();
|
|
2814
|
+
const branchRetries = /* @__PURE__ */ new Map();
|
|
2815
|
+
const MAX_BRANCH_RETRIES = 3;
|
|
2816
|
+
let permissionCheckDone = false;
|
|
2817
|
+
try {
|
|
2818
|
+
const inProgressTasks = allTasks.filter((t) => t.status === "in_progress");
|
|
2819
|
+
if (inProgressTasks.length > 0) {
|
|
2820
|
+
console.log(warning(`
|
|
2821
|
+
Resuming ${String(inProgressTasks.length)} in-progress task(s):`));
|
|
2822
|
+
for (const t of inProgressTasks) {
|
|
2823
|
+
console.log(warning(` - ${t.id}: ${t.name}`));
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
while (completedCount < targetCount) {
|
|
2827
|
+
const manager = ProcessManager.getInstance();
|
|
2828
|
+
if (manager.isShuttingDown()) {
|
|
2829
|
+
break;
|
|
2830
|
+
}
|
|
2831
|
+
await coordinator.waitIfPaused();
|
|
2832
|
+
const readyTasks = await getReadyTasks(sprintId);
|
|
2833
|
+
const currentTasks = await getTasks(sprintId);
|
|
2834
|
+
const inProgress = currentTasks.filter((t) => t.status === "in_progress" && !running.has(t.id));
|
|
2835
|
+
const launchCandidates = [...inProgress, ...readyTasks.filter((t) => !inProgress.some((ip) => ip.id === t.id))];
|
|
2836
|
+
if (launchCandidates.length === 0 && running.size === 0) {
|
|
2837
|
+
const remaining = await getRemainingTasks(sprintId);
|
|
2838
|
+
if (remaining.length === 0) {
|
|
2839
|
+
if (completedCount === 0) {
|
|
2840
|
+
return {
|
|
2841
|
+
completed: 0,
|
|
2842
|
+
remaining: 0,
|
|
2843
|
+
stopReason: "no_tasks",
|
|
2844
|
+
blockedTask: null,
|
|
2845
|
+
blockedReason: null,
|
|
2846
|
+
exitCode: EXIT_NO_TASKS
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
console.log(success("\nAll tasks completed!"));
|
|
2850
|
+
return {
|
|
2851
|
+
completed: completedCount,
|
|
2852
|
+
remaining: 0,
|
|
2853
|
+
stopReason: "all_completed",
|
|
2854
|
+
blockedTask: null,
|
|
2855
|
+
blockedReason: null,
|
|
2856
|
+
exitCode: EXIT_SUCCESS
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
return {
|
|
2860
|
+
completed: completedCount,
|
|
2861
|
+
remaining: remaining.length,
|
|
2862
|
+
stopReason: hasFailed ? "task_blocked" : "all_blocked",
|
|
2863
|
+
blockedTask: firstBlockedTask,
|
|
2864
|
+
blockedReason: firstBlockedReason ?? "All remaining tasks are blocked by dependencies",
|
|
2865
|
+
exitCode: hasFailed ? EXIT_ERROR : EXIT_ALL_BLOCKED
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
if (!hasFailed || !failFast) {
|
|
2869
|
+
const toStart = pickTasksToLaunch(launchCandidates, inFlightPaths, concurrencyLimit, running.size);
|
|
2870
|
+
for (const task of toStart) {
|
|
2871
|
+
if (completedCount + running.size >= targetCount) break;
|
|
2872
|
+
const project = await getProjectForTask(task, sprint);
|
|
2873
|
+
if (!permissionCheckDone) {
|
|
2874
|
+
const ctx = { sprint, task, project };
|
|
2875
|
+
runPermissionCheck(ctx, options.noCommit, provider.name);
|
|
2876
|
+
permissionCheckDone = true;
|
|
2877
|
+
}
|
|
2878
|
+
if (sprint.branch) {
|
|
2879
|
+
if (!verifySprintBranch(task.projectPath, sprint.branch)) {
|
|
2880
|
+
const attempt = (branchRetries.get(task.id) ?? 0) + 1;
|
|
2881
|
+
branchRetries.set(task.id, attempt);
|
|
2882
|
+
if (attempt < MAX_BRANCH_RETRIES) {
|
|
2883
|
+
console.log(
|
|
2884
|
+
warning(
|
|
2885
|
+
`
|
|
2886
|
+
Branch verification failed (attempt ${String(attempt)}/${String(MAX_BRANCH_RETRIES)}): expected '${sprint.branch}' in ${task.projectPath}`
|
|
2887
|
+
)
|
|
2888
|
+
);
|
|
2889
|
+
console.log(muted(` Task ${task.id} will retry on next loop iteration.`));
|
|
2890
|
+
continue;
|
|
2891
|
+
}
|
|
2892
|
+
console.log(
|
|
2893
|
+
warning(
|
|
2894
|
+
`
|
|
2895
|
+
Branch verification failed after ${String(MAX_BRANCH_RETRIES)} attempts: expected '${sprint.branch}' in ${task.projectPath}`
|
|
2896
|
+
)
|
|
2897
|
+
);
|
|
2898
|
+
console.log(muted(` Task ${task.id} not started \u2014 wrong branch.`));
|
|
2899
|
+
hasFailed = true;
|
|
2900
|
+
if (!firstBlockedTask) {
|
|
2901
|
+
firstBlockedTask = task;
|
|
2902
|
+
firstBlockedReason = `Repository ${task.projectPath} is not on expected branch '${sprint.branch}'`;
|
|
2903
|
+
}
|
|
2904
|
+
if (failFast) {
|
|
2905
|
+
console.log(muted("Fail-fast: waiting for running tasks to finish..."));
|
|
2906
|
+
}
|
|
2907
|
+
continue;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
if (task.status !== "in_progress") {
|
|
2911
|
+
await updateTaskStatus(task.id, "in_progress", sprintId);
|
|
2912
|
+
}
|
|
2913
|
+
const resumeId = taskSessionIds.get(task.id);
|
|
2914
|
+
const action = resumeId ? "Resuming" : "Starting";
|
|
2915
|
+
console.log(info(`
|
|
2916
|
+
--- ${action} task ${String(task.order)}: ${task.name} ---`));
|
|
2917
|
+
console.log(info("ID: ") + task.id);
|
|
2918
|
+
console.log(info("Project: ") + task.projectPath);
|
|
2919
|
+
if (resumeId) {
|
|
2920
|
+
console.log(muted(`Resuming ${label} session ${resumeId.slice(0, 8)}...`));
|
|
2921
|
+
} else {
|
|
2922
|
+
console.log(muted(`Starting ${label} in ${task.projectPath} (headless)...`));
|
|
2923
|
+
}
|
|
2924
|
+
inFlightPaths.add(task.projectPath);
|
|
2925
|
+
const taskPromise = (async () => {
|
|
2926
|
+
try {
|
|
2927
|
+
const ctx = { sprint, task, project };
|
|
2928
|
+
const result = await executeTask(
|
|
2929
|
+
ctx,
|
|
2930
|
+
options,
|
|
2931
|
+
sprintId,
|
|
2932
|
+
resumeId,
|
|
2933
|
+
provider,
|
|
2934
|
+
checkResults?.get(task.projectPath)
|
|
2935
|
+
);
|
|
2936
|
+
if (result.sessionId) {
|
|
2937
|
+
taskSessionIds.set(task.id, result.sessionId);
|
|
2938
|
+
}
|
|
2939
|
+
return { task, result, error: null, isRateLimited: false };
|
|
2940
|
+
} catch (err) {
|
|
2941
|
+
if (err instanceof SpawnError && err.rateLimited) {
|
|
2942
|
+
if (err.sessionId) {
|
|
2943
|
+
taskSessionIds.set(task.id, err.sessionId);
|
|
2944
|
+
}
|
|
2945
|
+
const delay = err.retryAfterMs ?? 6e4;
|
|
2946
|
+
coordinator.pause(delay);
|
|
2947
|
+
return {
|
|
2948
|
+
task,
|
|
2949
|
+
result: null,
|
|
2950
|
+
error: err,
|
|
2951
|
+
isRateLimited: true
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
return {
|
|
2955
|
+
task,
|
|
2956
|
+
result: null,
|
|
2957
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
2958
|
+
isRateLimited: false
|
|
2959
|
+
};
|
|
2960
|
+
} finally {
|
|
2961
|
+
inFlightPaths.delete(task.projectPath);
|
|
2962
|
+
}
|
|
2963
|
+
})();
|
|
2964
|
+
running.set(task.id, taskPromise);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
if (running.size === 0) {
|
|
2968
|
+
const hasPendingBranchRetry = [...branchRetries.entries()].some(([, count]) => count < MAX_BRANCH_RETRIES);
|
|
2969
|
+
if (hasPendingBranchRetry) {
|
|
2970
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
2971
|
+
continue;
|
|
2972
|
+
}
|
|
2973
|
+
break;
|
|
2974
|
+
}
|
|
2975
|
+
const settled = await Promise.race([...running.values()]);
|
|
2976
|
+
running.delete(settled.task.id);
|
|
2977
|
+
if (settled.error) {
|
|
2978
|
+
if (settled.isRateLimited) {
|
|
2979
|
+
const sessionId = taskSessionIds.get(settled.task.id);
|
|
2980
|
+
console.log(warning(`
|
|
2981
|
+
Rate limited: ${settled.task.name}`));
|
|
2982
|
+
if (sessionId) {
|
|
2983
|
+
console.log(muted(`Session saved for resume: ${sessionId.slice(0, 8)}...`));
|
|
2984
|
+
}
|
|
2985
|
+
console.log(muted("Will retry after cooldown."));
|
|
2986
|
+
continue;
|
|
2987
|
+
}
|
|
2988
|
+
console.log(warning(`
|
|
2989
|
+
Task failed: ${settled.task.name}`));
|
|
2990
|
+
console.log(warning(`Error: ${settled.error.message}`));
|
|
2991
|
+
console.log(muted(`Task ${settled.task.id} remains in_progress for resumption.`));
|
|
2992
|
+
hasFailed = true;
|
|
2993
|
+
if (!firstBlockedTask) {
|
|
2994
|
+
firstBlockedTask = settled.task;
|
|
2995
|
+
firstBlockedReason = settled.error.message;
|
|
2996
|
+
}
|
|
2997
|
+
if (failFast) {
|
|
2998
|
+
console.log(muted("Fail-fast: waiting for running tasks to finish..."));
|
|
2999
|
+
}
|
|
3000
|
+
continue;
|
|
3001
|
+
}
|
|
3002
|
+
if (settled.result && !settled.result.success) {
|
|
3003
|
+
console.log(warning(`
|
|
3004
|
+
Task not completed: ${settled.task.name}`));
|
|
3005
|
+
if (settled.result.blockedReason) {
|
|
3006
|
+
console.log(warning(`Reason: ${settled.result.blockedReason}`));
|
|
3007
|
+
}
|
|
3008
|
+
console.log(muted(`Task ${settled.task.id} remains in_progress.`));
|
|
3009
|
+
hasFailed = true;
|
|
3010
|
+
if (!firstBlockedTask) {
|
|
3011
|
+
firstBlockedTask = settled.task;
|
|
3012
|
+
firstBlockedReason = settled.result.blockedReason ?? "Unknown reason";
|
|
3013
|
+
}
|
|
3014
|
+
if (failFast) {
|
|
3015
|
+
console.log(muted("Fail-fast: waiting for running tasks to finish..."));
|
|
3016
|
+
}
|
|
3017
|
+
continue;
|
|
3018
|
+
}
|
|
3019
|
+
if (settled.result) {
|
|
3020
|
+
if (settled.result.verified) {
|
|
3021
|
+
await updateTask(
|
|
3022
|
+
settled.task.id,
|
|
3023
|
+
{
|
|
3024
|
+
verified: true,
|
|
3025
|
+
verificationOutput: settled.result.verificationOutput
|
|
3026
|
+
},
|
|
3027
|
+
sprintId
|
|
3028
|
+
);
|
|
3029
|
+
console.log(success(`Verification passed: ${settled.task.name}`));
|
|
3030
|
+
}
|
|
3031
|
+
const taskProject = await getProjectForTask(settled.task, sprint);
|
|
3032
|
+
const taskCheckScript = getEffectiveCheckScript(taskProject, settled.task.projectPath);
|
|
3033
|
+
if (taskCheckScript) {
|
|
3034
|
+
const hookResult = runLifecycleHook(settled.task.projectPath, taskCheckScript, "taskComplete");
|
|
3035
|
+
if (!hookResult.passed) {
|
|
3036
|
+
console.log(warning(`
|
|
3037
|
+
Post-task check failed for: ${settled.task.name}`));
|
|
3038
|
+
console.log(muted(`Task ${settled.task.id} remains in_progress.`));
|
|
3039
|
+
hasFailed = true;
|
|
3040
|
+
if (!firstBlockedTask) {
|
|
3041
|
+
firstBlockedTask = settled.task;
|
|
3042
|
+
firstBlockedReason = `Post-task check failed: ${hookResult.output.slice(0, 500)}`;
|
|
3043
|
+
}
|
|
3044
|
+
if (failFast) {
|
|
3045
|
+
console.log(muted("Fail-fast: waiting for running tasks to finish..."));
|
|
3046
|
+
}
|
|
3047
|
+
continue;
|
|
3048
|
+
}
|
|
3049
|
+
console.log(success(`Post-task check passed: ${settled.task.name}`));
|
|
3050
|
+
}
|
|
3051
|
+
await updateTaskStatus(settled.task.id, "done", sprintId);
|
|
3052
|
+
console.log(success(`Completed: ${settled.task.name}`));
|
|
3053
|
+
taskSessionIds.delete(settled.task.id);
|
|
3054
|
+
await logProgress(
|
|
3055
|
+
`Completed task: ${settled.task.id} - ${settled.task.name}
|
|
3056
|
+
|
|
3057
|
+
` + (settled.task.description ? `Description: ${settled.task.description}
|
|
3058
|
+
` : "") + (settled.task.steps.length > 0 ? `Steps:
|
|
3059
|
+
${settled.task.steps.map((s, i) => ` ${String(i + 1)}. ${s}`).join("\n")}` : ""),
|
|
3060
|
+
{ sprintId, projectPath: settled.task.projectPath }
|
|
3061
|
+
);
|
|
3062
|
+
completedCount++;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
if (running.size > 0) {
|
|
3066
|
+
console.log(muted(`
|
|
3067
|
+
Waiting for ${String(running.size)} remaining task(s)...`));
|
|
3068
|
+
const remaining = await Promise.allSettled([...running.values()]);
|
|
3069
|
+
for (const r of remaining) {
|
|
3070
|
+
if (r.status === "fulfilled" && r.value.result?.success) {
|
|
3071
|
+
if (r.value.result.verified) {
|
|
3072
|
+
await updateTask(
|
|
3073
|
+
r.value.task.id,
|
|
3074
|
+
{ verified: true, verificationOutput: r.value.result.verificationOutput },
|
|
3075
|
+
sprintId
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
const drainProject = await getProjectForTask(r.value.task, sprint);
|
|
3079
|
+
const drainCheckScript = getEffectiveCheckScript(drainProject, r.value.task.projectPath);
|
|
3080
|
+
if (drainCheckScript) {
|
|
3081
|
+
const hookResult = runLifecycleHook(r.value.task.projectPath, drainCheckScript, "taskComplete");
|
|
3082
|
+
if (!hookResult.passed) {
|
|
3083
|
+
console.log(warning(`Post-task check failed for: ${r.value.task.name}`));
|
|
3084
|
+
continue;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
await updateTaskStatus(r.value.task.id, "done", sprintId);
|
|
3088
|
+
console.log(success(`Completed: ${r.value.task.name}`));
|
|
3089
|
+
await logProgress(`Completed task: ${r.value.task.id} - ${r.value.task.name}`, {
|
|
3090
|
+
sprintId,
|
|
3091
|
+
projectPath: r.value.task.projectPath
|
|
3092
|
+
});
|
|
3093
|
+
completedCount++;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
} finally {
|
|
3098
|
+
coordinator.dispose();
|
|
3099
|
+
}
|
|
3100
|
+
const remainingTasks = await getRemainingTasks(sprintId);
|
|
3101
|
+
if (hasFailed) {
|
|
3102
|
+
return {
|
|
3103
|
+
completed: completedCount,
|
|
3104
|
+
remaining: remainingTasks.length,
|
|
3105
|
+
stopReason: "task_blocked",
|
|
3106
|
+
blockedTask: firstBlockedTask,
|
|
3107
|
+
blockedReason: firstBlockedReason,
|
|
3108
|
+
exitCode: EXIT_ERROR
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
return {
|
|
3112
|
+
completed: completedCount,
|
|
3113
|
+
remaining: remainingTasks.length,
|
|
3114
|
+
stopReason: remainingTasks.length === 0 ? "all_completed" : "count_reached",
|
|
3115
|
+
blockedTask: null,
|
|
3116
|
+
blockedReason: null,
|
|
3117
|
+
exitCode: EXIT_SUCCESS
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// src/utils/git.ts
|
|
3122
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
3123
|
+
var BRANCH_NAME_RE = /^[a-zA-Z0-9/_.-]+$/;
|
|
3124
|
+
var BRANCH_NAME_INVALID_PATTERNS = [/\.\./, /\.$/, /\/$/, /\.lock$/, /^-/, /\/\//];
|
|
3125
|
+
function isValidBranchName(name) {
|
|
3126
|
+
if (!name || name.length > 250) return false;
|
|
3127
|
+
if (!BRANCH_NAME_RE.test(name)) return false;
|
|
3128
|
+
for (const pattern of BRANCH_NAME_INVALID_PATTERNS) {
|
|
3129
|
+
if (pattern.test(name)) return false;
|
|
3130
|
+
}
|
|
3131
|
+
return true;
|
|
3132
|
+
}
|
|
3133
|
+
function getCurrentBranch(cwd) {
|
|
3134
|
+
assertSafeCwd(cwd);
|
|
3135
|
+
const result = spawnSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
3136
|
+
cwd,
|
|
3137
|
+
encoding: "utf-8",
|
|
3138
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3139
|
+
});
|
|
3140
|
+
if (result.status !== 0) {
|
|
3141
|
+
throw new Error(`Failed to get current branch in ${cwd}: ${result.stderr.trim()}`);
|
|
3142
|
+
}
|
|
3143
|
+
return result.stdout.trim();
|
|
3144
|
+
}
|
|
3145
|
+
function branchExists(cwd, name) {
|
|
3146
|
+
assertSafeCwd(cwd);
|
|
3147
|
+
if (!isValidBranchName(name)) {
|
|
3148
|
+
throw new Error(`Invalid branch name: ${name}`);
|
|
3149
|
+
}
|
|
3150
|
+
const result = spawnSync3("git", ["show-ref", "--verify", `refs/heads/${name}`], {
|
|
3151
|
+
cwd,
|
|
3152
|
+
encoding: "utf-8",
|
|
3153
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3154
|
+
});
|
|
3155
|
+
return result.status === 0;
|
|
3156
|
+
}
|
|
3157
|
+
function createAndCheckoutBranch(cwd, name) {
|
|
3158
|
+
assertSafeCwd(cwd);
|
|
3159
|
+
if (!isValidBranchName(name)) {
|
|
3160
|
+
throw new Error(`Invalid branch name: ${name}`);
|
|
3161
|
+
}
|
|
3162
|
+
const current = getCurrentBranch(cwd);
|
|
3163
|
+
if (current === name) {
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
if (branchExists(cwd, name)) {
|
|
3167
|
+
const result = spawnSync3("git", ["checkout", name], {
|
|
3168
|
+
cwd,
|
|
3169
|
+
encoding: "utf-8",
|
|
3170
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3171
|
+
});
|
|
3172
|
+
if (result.status !== 0) {
|
|
3173
|
+
throw new Error(`Failed to checkout branch '${name}' in ${cwd}: ${result.stderr.trim()}`);
|
|
3174
|
+
}
|
|
3175
|
+
} else {
|
|
3176
|
+
const result = spawnSync3("git", ["checkout", "-b", name], {
|
|
3177
|
+
cwd,
|
|
3178
|
+
encoding: "utf-8",
|
|
3179
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3180
|
+
});
|
|
3181
|
+
if (result.status !== 0) {
|
|
3182
|
+
throw new Error(`Failed to create branch '${name}' in ${cwd}: ${result.stderr.trim()}`);
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
function verifyCurrentBranch(cwd, expected) {
|
|
3187
|
+
const current = getCurrentBranch(cwd);
|
|
3188
|
+
return current === expected;
|
|
3189
|
+
}
|
|
3190
|
+
function getDefaultBranch(cwd) {
|
|
3191
|
+
assertSafeCwd(cwd);
|
|
3192
|
+
const result = spawnSync3("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
3193
|
+
cwd,
|
|
3194
|
+
encoding: "utf-8",
|
|
3195
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3196
|
+
});
|
|
3197
|
+
if (result.status === 0) {
|
|
3198
|
+
const ref = result.stdout.trim();
|
|
3199
|
+
const parts = ref.split("/");
|
|
3200
|
+
return parts[parts.length - 1] ?? "main";
|
|
3201
|
+
}
|
|
3202
|
+
const stderr = result.stderr.trim();
|
|
3203
|
+
if (stderr.includes("is not a symbolic ref") || stderr.includes("No such ref")) {
|
|
3204
|
+
if (branchExists(cwd, "main")) return "main";
|
|
3205
|
+
if (branchExists(cwd, "master")) return "master";
|
|
3206
|
+
return "main";
|
|
3207
|
+
}
|
|
3208
|
+
throw new Error(`Failed to detect default branch in ${cwd}: ${stderr}`);
|
|
3209
|
+
}
|
|
3210
|
+
function hasUncommittedChanges(cwd) {
|
|
3211
|
+
assertSafeCwd(cwd);
|
|
3212
|
+
const result = spawnSync3("git", ["status", "--porcelain"], {
|
|
3213
|
+
cwd,
|
|
3214
|
+
encoding: "utf-8",
|
|
3215
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3216
|
+
});
|
|
3217
|
+
if (result.status !== 0) {
|
|
3218
|
+
throw new Error(`Failed to check git status in ${cwd}: ${result.stderr.trim()}`);
|
|
3219
|
+
}
|
|
3220
|
+
return result.stdout.trim().length > 0;
|
|
3221
|
+
}
|
|
3222
|
+
function generateBranchName(sprintId) {
|
|
3223
|
+
return `ralphctl/${sprintId}`;
|
|
3224
|
+
}
|
|
3225
|
+
function isGhAvailable() {
|
|
3226
|
+
const result = spawnSync3("gh", ["--version"], {
|
|
3227
|
+
encoding: "utf-8",
|
|
3228
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3229
|
+
});
|
|
3230
|
+
return result.status === 0;
|
|
3231
|
+
}
|
|
3232
|
+
function isGlabAvailable() {
|
|
3233
|
+
const result = spawnSync3("glab", ["--version"], {
|
|
3234
|
+
encoding: "utf-8",
|
|
3235
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3236
|
+
});
|
|
3237
|
+
return result.status === 0;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// src/ai/runner.ts
|
|
3241
|
+
async function promptBranchStrategy(sprintId) {
|
|
3242
|
+
const autoBranch = generateBranchName(sprintId);
|
|
3243
|
+
const strategy = await select2({
|
|
3244
|
+
message: "How should this sprint manage branches?",
|
|
3245
|
+
choices: [
|
|
3246
|
+
{
|
|
3247
|
+
name: `Create sprint branch: ${autoBranch} (Recommended)`,
|
|
3248
|
+
value: "auto"
|
|
3249
|
+
},
|
|
3250
|
+
{
|
|
3251
|
+
name: "Keep current branch (no branch management)",
|
|
3252
|
+
value: "keep"
|
|
3253
|
+
},
|
|
3254
|
+
{
|
|
3255
|
+
name: "Custom branch name",
|
|
3256
|
+
value: "custom"
|
|
3257
|
+
}
|
|
3258
|
+
]
|
|
3259
|
+
});
|
|
3260
|
+
if (strategy === "keep") return null;
|
|
3261
|
+
if (strategy === "auto") return autoBranch;
|
|
3262
|
+
const customName = await input2({
|
|
3263
|
+
message: "Enter branch name:",
|
|
3264
|
+
validate: (value) => {
|
|
3265
|
+
if (!value.trim()) return "Branch name cannot be empty";
|
|
3266
|
+
if (!isValidBranchName(value.trim())) {
|
|
3267
|
+
return "Invalid branch name. Use alphanumeric characters, hyphens, underscores, dots, and slashes.";
|
|
3268
|
+
}
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
});
|
|
3272
|
+
return customName.trim();
|
|
3273
|
+
}
|
|
3274
|
+
async function resolveBranch(sprintId, sprint, options) {
|
|
3275
|
+
if (options.branchName) return options.branchName;
|
|
3276
|
+
if (options.branch) return generateBranchName(sprintId);
|
|
3277
|
+
if (sprint.branch) return sprint.branch;
|
|
3278
|
+
return promptBranchStrategy(sprintId);
|
|
3279
|
+
}
|
|
3280
|
+
async function ensureSprintBranches(sprintId, sprint, branchName) {
|
|
3281
|
+
if (!isValidBranchName(branchName)) {
|
|
3282
|
+
throw new Error(`Invalid branch name: ${branchName}`);
|
|
3283
|
+
}
|
|
3284
|
+
const tasks = await getTasks(sprintId);
|
|
3285
|
+
const remainingTasks = tasks.filter((t) => t.status !== "done");
|
|
3286
|
+
const uniquePaths = [...new Set(remainingTasks.map((t) => t.projectPath))];
|
|
3287
|
+
if (uniquePaths.length === 0) return;
|
|
3288
|
+
for (const projectPath of uniquePaths) {
|
|
3289
|
+
try {
|
|
3290
|
+
if (hasUncommittedChanges(projectPath)) {
|
|
3291
|
+
throw new Error(
|
|
3292
|
+
`Repository at ${projectPath} has uncommitted changes. Commit or stash them before starting the sprint.`
|
|
3293
|
+
);
|
|
3294
|
+
}
|
|
3295
|
+
} catch (err) {
|
|
3296
|
+
if (err instanceof Error && err.message.includes("uncommitted changes")) {
|
|
3297
|
+
throw err;
|
|
3298
|
+
}
|
|
3299
|
+
log.dim(` Skipping ${projectPath} \u2014 not a git repository`);
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
for (const projectPath of uniquePaths) {
|
|
3304
|
+
try {
|
|
3305
|
+
const currentBranch = getCurrentBranch(projectPath);
|
|
3306
|
+
if (currentBranch === branchName) {
|
|
3307
|
+
log.dim(` Already on branch '${branchName}' in ${projectPath}`);
|
|
3308
|
+
} else {
|
|
3309
|
+
createAndCheckoutBranch(projectPath, branchName);
|
|
3310
|
+
log.success(` Branch '${branchName}' ready in ${projectPath}`);
|
|
3311
|
+
}
|
|
3312
|
+
} catch (err) {
|
|
3313
|
+
throw new Error(
|
|
3314
|
+
`Failed to create branch '${branchName}' in ${projectPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
3315
|
+
{ cause: err }
|
|
3316
|
+
);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
if (sprint.branch !== branchName) {
|
|
3320
|
+
sprint.branch = branchName;
|
|
3321
|
+
await saveSprint(sprint);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
function verifySprintBranch(projectPath, expectedBranch) {
|
|
3325
|
+
try {
|
|
3326
|
+
if (verifyCurrentBranch(projectPath, expectedBranch)) {
|
|
3327
|
+
return true;
|
|
3328
|
+
}
|
|
3329
|
+
log.dim(` Branch mismatch in ${projectPath} \u2014 checking out '${expectedBranch}'`);
|
|
3330
|
+
createAndCheckoutBranch(projectPath, expectedBranch);
|
|
3331
|
+
return verifyCurrentBranch(projectPath, expectedBranch);
|
|
3332
|
+
} catch {
|
|
3333
|
+
return false;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
async function runCheckScripts(sprintId, sprint, refreshCheck = false) {
|
|
3337
|
+
const results = /* @__PURE__ */ new Map();
|
|
3338
|
+
const tasks = await getTasks(sprintId);
|
|
3339
|
+
const remainingTasks = tasks.filter((t) => t.status !== "done");
|
|
3340
|
+
const uniquePaths = [...new Set(remainingTasks.map((t) => t.projectPath))];
|
|
3341
|
+
if (uniquePaths.length === 0) {
|
|
3342
|
+
return { success: true, results };
|
|
3343
|
+
}
|
|
3344
|
+
for (const projectPath of uniquePaths) {
|
|
3345
|
+
const taskForPath = remainingTasks.find((t) => t.projectPath === projectPath);
|
|
3346
|
+
if (!taskForPath) continue;
|
|
3347
|
+
const project = await getProjectForTask(taskForPath, sprint);
|
|
3348
|
+
const checkScript = getEffectiveCheckScript(project, projectPath);
|
|
3349
|
+
const repo = project?.repositories.find((r) => r.path === projectPath);
|
|
3350
|
+
const repoName = repo?.name ?? projectPath;
|
|
3351
|
+
if (!checkScript) {
|
|
3352
|
+
log.dim(` No check script for ${repoName} \u2014 configure via 'project add'`);
|
|
3353
|
+
results.set(projectPath, { ran: false, reason: "no-script" });
|
|
3354
|
+
continue;
|
|
3355
|
+
}
|
|
3356
|
+
const previousRun = sprint.checkRanAt[projectPath];
|
|
3357
|
+
if (previousRun && !refreshCheck) {
|
|
3358
|
+
log.dim(` Check already ran for ${repoName} at ${previousRun} \u2014 skipping`);
|
|
3359
|
+
results.set(projectPath, { ran: true, script: checkScript });
|
|
3360
|
+
continue;
|
|
3361
|
+
}
|
|
3362
|
+
log.info(`
|
|
3363
|
+
Running check for ${repoName}: ${checkScript}`);
|
|
3364
|
+
const hookResult = runLifecycleHook(projectPath, checkScript, "sprintStart");
|
|
3365
|
+
if (!hookResult.passed) {
|
|
3366
|
+
return {
|
|
3367
|
+
success: false,
|
|
3368
|
+
error: `Check failed for ${repoName}: ${checkScript}
|
|
3369
|
+
${hookResult.output}`
|
|
3370
|
+
};
|
|
3371
|
+
}
|
|
3372
|
+
sprint.checkRanAt[projectPath] = (/* @__PURE__ */ new Date()).toISOString();
|
|
3373
|
+
await saveSprint(sprint);
|
|
3374
|
+
log.success(`Check complete: ${repoName}`);
|
|
3375
|
+
results.set(projectPath, { ran: true, script: checkScript });
|
|
3376
|
+
}
|
|
3377
|
+
return { success: true, results };
|
|
3378
|
+
}
|
|
3379
|
+
function shouldRunParallel(options) {
|
|
3380
|
+
if (options.session) return false;
|
|
3381
|
+
if (options.step) return false;
|
|
3382
|
+
if (options.concurrency === 1) return false;
|
|
3383
|
+
return true;
|
|
3384
|
+
}
|
|
3385
|
+
async function runSprint(sprintId, options) {
|
|
3386
|
+
const id = await resolveSprintId(sprintId);
|
|
3387
|
+
let sprint = await getSprint(id);
|
|
3388
|
+
if (sprint.status === "draft" && !options.force) {
|
|
3389
|
+
const unrefinedTickets = getPendingRequirements(sprint.tickets);
|
|
3390
|
+
if (unrefinedTickets.length > 0) {
|
|
3391
|
+
showWarning(
|
|
3392
|
+
`Sprint has ${String(unrefinedTickets.length)} unrefined ticket${unrefinedTickets.length !== 1 ? "s" : ""}:`
|
|
3393
|
+
);
|
|
3394
|
+
for (const ticket of unrefinedTickets) {
|
|
3395
|
+
log.item(`${formatTicketId(ticket)} \u2014 ${ticket.title}`);
|
|
3396
|
+
}
|
|
3397
|
+
log.newline();
|
|
3398
|
+
const shouldContinue = await confirm5({
|
|
3399
|
+
message: "Start anyway without refining?",
|
|
3400
|
+
default: false
|
|
3401
|
+
});
|
|
3402
|
+
if (!shouldContinue) {
|
|
3403
|
+
log.dim("Run 'sprint refine' first, or use --force to skip this check.");
|
|
3404
|
+
log.newline();
|
|
3405
|
+
return void 0;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
if (sprint.status === "draft" && !options.force) {
|
|
3410
|
+
const tasks = await getTasks(id);
|
|
3411
|
+
const ticketIdsWithTasks = new Set(tasks.map((t) => t.ticketId).filter(Boolean));
|
|
3412
|
+
const unplannedTickets = sprint.tickets.filter(
|
|
3413
|
+
(t) => t.requirementStatus === "approved" && !ticketIdsWithTasks.has(t.id)
|
|
3414
|
+
);
|
|
3415
|
+
if (unplannedTickets.length > 0) {
|
|
3416
|
+
showWarning("Sprint has refined tickets with no planned tasks:");
|
|
3417
|
+
for (const ticket of unplannedTickets) {
|
|
3418
|
+
log.item(`${formatTicketId(ticket)} \u2014 ${ticket.title}`);
|
|
3419
|
+
}
|
|
3420
|
+
log.newline();
|
|
3421
|
+
const shouldContinue = await confirm5({
|
|
3422
|
+
message: "Start anyway without planning?",
|
|
3423
|
+
default: false
|
|
3424
|
+
});
|
|
3425
|
+
if (!shouldContinue) {
|
|
3426
|
+
log.dim("Run 'sprint plan' first, or use --force to skip this check.");
|
|
3427
|
+
log.newline();
|
|
3428
|
+
return void 0;
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
const branchName = await resolveBranch(id, sprint, options);
|
|
3433
|
+
if (sprint.status === "draft") {
|
|
3434
|
+
sprint = await activateSprint(id);
|
|
3435
|
+
}
|
|
3436
|
+
assertSprintStatus(sprint, ["active"], "start");
|
|
3437
|
+
printHeader("Sprint Start");
|
|
3438
|
+
log.info(`Sprint: ${sprint.name}`);
|
|
3439
|
+
log.info(`ID: ${sprint.id}`);
|
|
3440
|
+
const modes = [];
|
|
3441
|
+
if (options.session) {
|
|
3442
|
+
modes.push("session");
|
|
3443
|
+
} else {
|
|
3444
|
+
modes.push("headless");
|
|
3445
|
+
}
|
|
3446
|
+
if (options.step) {
|
|
3447
|
+
modes.push("step-by-step");
|
|
3448
|
+
}
|
|
3449
|
+
if (options.noCommit) {
|
|
3450
|
+
modes.push("no-commit");
|
|
3451
|
+
}
|
|
3452
|
+
const parallel = shouldRunParallel(options);
|
|
3453
|
+
if (parallel) {
|
|
3454
|
+
modes.push("parallel");
|
|
3455
|
+
}
|
|
3456
|
+
log.dim(`Mode: ${modes.join(", ")}`);
|
|
3457
|
+
if (options.count) {
|
|
3458
|
+
log.dim(`Limit: ${String(options.count)} task(s)`);
|
|
3459
|
+
}
|
|
3460
|
+
if (branchName) {
|
|
3461
|
+
log.info(`Branch: ${branchName}`);
|
|
3462
|
+
}
|
|
3463
|
+
if (branchName) {
|
|
3464
|
+
try {
|
|
3465
|
+
await ensureSprintBranches(id, sprint, branchName);
|
|
3466
|
+
} catch (err) {
|
|
3467
|
+
log.newline();
|
|
3468
|
+
showError(err instanceof Error ? err.message : String(err));
|
|
3469
|
+
log.newline();
|
|
3470
|
+
return void 0;
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
try {
|
|
3474
|
+
await reorderByDependencies(id);
|
|
3475
|
+
log.dim("Tasks reordered by dependencies");
|
|
3476
|
+
} catch (err) {
|
|
3477
|
+
if (err instanceof DependencyCycleError) {
|
|
3478
|
+
log.newline();
|
|
3479
|
+
showWarning(err.message);
|
|
3480
|
+
log.dim("Fix the dependency cycle before starting.");
|
|
3481
|
+
log.newline();
|
|
3482
|
+
return void 0;
|
|
3483
|
+
}
|
|
3484
|
+
throw err;
|
|
3485
|
+
}
|
|
3486
|
+
const checkResult = await runCheckScripts(id, sprint, options.refreshCheck);
|
|
3487
|
+
if (!checkResult.success) {
|
|
3488
|
+
log.newline();
|
|
3489
|
+
showError(checkResult.error);
|
|
3490
|
+
log.newline();
|
|
3491
|
+
return void 0;
|
|
3492
|
+
}
|
|
3493
|
+
const summary = parallel ? await executeTaskLoopParallel(id, options, checkResult.results) : await executeTaskLoop(id, options, checkResult.results);
|
|
3494
|
+
printHeader("Summary");
|
|
3495
|
+
log.info(`Completed: ${String(summary.completed)} task(s)`);
|
|
3496
|
+
log.info(`Remaining: ${String(summary.remaining)} task(s)`);
|
|
3497
|
+
if (await areAllTasksDone(id)) {
|
|
3498
|
+
terminalBell();
|
|
3499
|
+
showSuccess("All tasks in sprint are done!");
|
|
3500
|
+
showRandomQuote();
|
|
3501
|
+
const shouldClose = await confirm5({
|
|
3502
|
+
message: "Close the sprint?",
|
|
3503
|
+
default: true
|
|
3504
|
+
});
|
|
3505
|
+
if (shouldClose) {
|
|
3506
|
+
await closeSprint(id);
|
|
3507
|
+
showSuccess(`Sprint closed: ${id}`);
|
|
3508
|
+
}
|
|
3509
|
+
} else if (summary.stopReason === "all_blocked") {
|
|
3510
|
+
log.newline();
|
|
3511
|
+
showWarning("All remaining tasks are blocked by dependencies.");
|
|
3512
|
+
const remaining = await getRemainingTasks(id);
|
|
3513
|
+
const blockedTasks = remaining.filter((t) => t.blockedBy.length > 0);
|
|
3514
|
+
if (blockedTasks.length > 0) {
|
|
3515
|
+
log.dim("Blocked tasks:");
|
|
3516
|
+
for (const t of blockedTasks.slice(0, 5)) {
|
|
3517
|
+
log.item(`${t.name} (blocked by: ${t.blockedBy.join(", ")})`);
|
|
3518
|
+
}
|
|
3519
|
+
if (blockedTasks.length > 5) {
|
|
3520
|
+
log.dim(` ... and ${String(blockedTasks.length - 5)} more`);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
log.newline();
|
|
3525
|
+
return summary;
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
// src/commands/sprint/start.ts
|
|
3529
|
+
function parseArgs3(args) {
|
|
3530
|
+
const options = {
|
|
3531
|
+
step: false,
|
|
3532
|
+
count: null,
|
|
3533
|
+
session: false,
|
|
3534
|
+
noCommit: false
|
|
3535
|
+
};
|
|
3536
|
+
let sprintId;
|
|
3537
|
+
for (let i = 0; i < args.length; i++) {
|
|
3538
|
+
const arg = args[i];
|
|
3539
|
+
if (arg === "-t" || arg === "--step") {
|
|
3540
|
+
options.step = true;
|
|
3541
|
+
} else if (arg === "-s" || arg === "--session") {
|
|
3542
|
+
options.session = true;
|
|
3543
|
+
} else if (arg === "--no-commit") {
|
|
3544
|
+
options.noCommit = true;
|
|
3545
|
+
} else if (arg === "-c" || arg === "--count") {
|
|
3546
|
+
const countStr = args[++i];
|
|
3547
|
+
if (!countStr) {
|
|
3548
|
+
throw new Error("--count requires a number");
|
|
3549
|
+
}
|
|
3550
|
+
const count = parseInt(countStr, 10);
|
|
3551
|
+
if (isNaN(count) || count < 1 || count > 1e4) {
|
|
3552
|
+
throw new Error("--count must be an integer between 1 and 10000");
|
|
3553
|
+
}
|
|
3554
|
+
options.count = count;
|
|
3555
|
+
} else if (arg === "--concurrency") {
|
|
3556
|
+
const concStr = args[++i];
|
|
3557
|
+
if (!concStr) {
|
|
3558
|
+
throw new Error("--concurrency requires a number");
|
|
3559
|
+
}
|
|
3560
|
+
const conc = parseInt(concStr, 10);
|
|
3561
|
+
if (isNaN(conc) || conc < 1 || conc > 10) {
|
|
3562
|
+
throw new Error("--concurrency must be an integer between 1 and 10");
|
|
3563
|
+
}
|
|
3564
|
+
options.concurrency = conc;
|
|
3565
|
+
} else if (arg === "--max-retries") {
|
|
3566
|
+
const retryStr = args[++i];
|
|
3567
|
+
if (!retryStr) {
|
|
3568
|
+
throw new Error("--max-retries requires a number");
|
|
3569
|
+
}
|
|
3570
|
+
const retries = parseInt(retryStr, 10);
|
|
3571
|
+
if (isNaN(retries) || retries < 0 || retries > 20) {
|
|
3572
|
+
throw new Error("--max-retries must be an integer between 0 and 20");
|
|
3573
|
+
}
|
|
3574
|
+
options.maxRetries = retries;
|
|
3575
|
+
} else if (arg === "--fail-fast") {
|
|
3576
|
+
options.failFast = true;
|
|
3577
|
+
} else if (arg === "--no-fail-fast") {
|
|
3578
|
+
options.failFast = false;
|
|
3579
|
+
} else if (arg === "-f" || arg === "--force") {
|
|
3580
|
+
options.force = true;
|
|
3581
|
+
} else if (arg === "--refresh-check") {
|
|
3582
|
+
options.refreshCheck = true;
|
|
3583
|
+
} else if (arg === "-b" || arg === "--branch") {
|
|
3584
|
+
options.branch = true;
|
|
3585
|
+
} else if (arg === "--branch-name") {
|
|
3586
|
+
const nameStr = args[++i];
|
|
3587
|
+
if (!nameStr) {
|
|
3588
|
+
throw new Error("--branch-name requires a value");
|
|
3589
|
+
}
|
|
3590
|
+
options.branchName = nameStr;
|
|
3591
|
+
} else if (!arg?.startsWith("-")) {
|
|
3592
|
+
sprintId = arg;
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
return { sprintId, options };
|
|
3596
|
+
}
|
|
3597
|
+
async function sprintStartCommand(args) {
|
|
3598
|
+
let sprintId;
|
|
3599
|
+
let options;
|
|
3600
|
+
try {
|
|
3601
|
+
const parsed = parseArgs3(args);
|
|
3602
|
+
sprintId = parsed.sprintId;
|
|
3603
|
+
options = parsed.options;
|
|
3604
|
+
} catch (err) {
|
|
3605
|
+
if (err instanceof Error) {
|
|
3606
|
+
showError(err.message);
|
|
3607
|
+
log.newline();
|
|
3608
|
+
}
|
|
3609
|
+
exitWithCode(EXIT_ERROR);
|
|
3610
|
+
}
|
|
3611
|
+
try {
|
|
3612
|
+
const summary = await runSprint(sprintId, options);
|
|
3613
|
+
if (summary) {
|
|
3614
|
+
exitWithCode(summary.exitCode);
|
|
3615
|
+
}
|
|
3616
|
+
} catch (err) {
|
|
3617
|
+
if (err instanceof SprintNotFoundError) {
|
|
3618
|
+
showError(`Sprint not found: ${sprintId ?? "unknown"}`);
|
|
3619
|
+
log.newline();
|
|
3620
|
+
exitWithCode(EXIT_ERROR);
|
|
3621
|
+
} else if (err instanceof SprintStatusError) {
|
|
3622
|
+
showError(err.message);
|
|
3623
|
+
log.newline();
|
|
3624
|
+
exitWithCode(EXIT_ERROR);
|
|
3625
|
+
} else if (err instanceof Error && err.message.includes("No sprint specified")) {
|
|
3626
|
+
showWarning("No sprint specified and no active sprint set.");
|
|
3627
|
+
showNextStep("ralphctl sprint start <id>", "specify a sprint ID");
|
|
3628
|
+
log.newline();
|
|
3629
|
+
exitWithCode(EXIT_NO_TASKS);
|
|
3630
|
+
} else {
|
|
3631
|
+
throw err;
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
export {
|
|
3637
|
+
TaskNotFoundError,
|
|
3638
|
+
getTasks,
|
|
3639
|
+
saveTasks,
|
|
3640
|
+
getTask,
|
|
3641
|
+
addTask,
|
|
3642
|
+
removeTask,
|
|
3643
|
+
updateTaskStatus,
|
|
3644
|
+
getNextTask,
|
|
3645
|
+
reorderTask,
|
|
3646
|
+
listTasks,
|
|
3647
|
+
areAllTasksDone,
|
|
3648
|
+
validateImportTasks,
|
|
3649
|
+
selectProject,
|
|
3650
|
+
selectProjectRepository,
|
|
3651
|
+
selectSprint,
|
|
3652
|
+
selectTicket,
|
|
3653
|
+
selectTask,
|
|
3654
|
+
selectTaskStatus,
|
|
3655
|
+
inputPositiveInt,
|
|
3656
|
+
selectProjectPaths,
|
|
3657
|
+
buildTicketRefinePrompt,
|
|
3658
|
+
buildIdeatePrompt,
|
|
3659
|
+
buildIdeateAutoPrompt,
|
|
3660
|
+
exportRequirementsToMarkdown,
|
|
3661
|
+
resolveProvider,
|
|
3662
|
+
providerDisplayName,
|
|
3663
|
+
getActiveProvider,
|
|
3664
|
+
spawnInteractive,
|
|
3665
|
+
spawnHeadless,
|
|
3666
|
+
extractJsonObject,
|
|
3667
|
+
formatTicketForPrompt,
|
|
3668
|
+
parseRequirementsFile,
|
|
3669
|
+
runAiSession,
|
|
3670
|
+
sprintRefineCommand,
|
|
3671
|
+
getTaskImportSchema,
|
|
3672
|
+
parsePlanningBlocked,
|
|
3673
|
+
parseTasksJson,
|
|
3674
|
+
renderParsedTasksTable,
|
|
3675
|
+
importTasks,
|
|
3676
|
+
sprintPlanCommand,
|
|
3677
|
+
getCurrentBranch,
|
|
3678
|
+
branchExists,
|
|
3679
|
+
getDefaultBranch,
|
|
3680
|
+
isGhAvailable,
|
|
3681
|
+
isGlabAvailable,
|
|
3682
|
+
sprintStartCommand
|
|
3683
|
+
};
|