ralphctl 0.2.4 → 0.3.0

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