ralphctl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/ralphctl +13 -0
- package/package.json +92 -0
- package/schemas/config.schema.json +20 -0
- package/schemas/ideate-output.schema.json +22 -0
- package/schemas/projects.schema.json +53 -0
- package/schemas/requirements-output.schema.json +24 -0
- package/schemas/sprint.schema.json +109 -0
- package/schemas/task-import.schema.json +49 -0
- package/schemas/tasks.schema.json +72 -0
- package/src/ai/executor.ts +973 -0
- package/src/ai/lifecycle.ts +45 -0
- package/src/ai/parser.ts +40 -0
- package/src/ai/permissions.ts +207 -0
- package/src/ai/process-manager.ts +248 -0
- package/src/ai/prompts/ideate-auto.md +144 -0
- package/src/ai/prompts/ideate.md +165 -0
- package/src/ai/prompts/index.ts +89 -0
- package/src/ai/prompts/plan-auto.md +131 -0
- package/src/ai/prompts/plan-common.md +157 -0
- package/src/ai/prompts/plan-interactive.md +190 -0
- package/src/ai/prompts/task-execution.md +159 -0
- package/src/ai/prompts/ticket-refine.md +230 -0
- package/src/ai/rate-limiter.ts +89 -0
- package/src/ai/runner.ts +478 -0
- package/src/ai/session.ts +319 -0
- package/src/ai/task-context.ts +270 -0
- package/src/cli-metadata.ts +7 -0
- package/src/cli.ts +65 -0
- package/src/commands/completion/index.ts +33 -0
- package/src/commands/config/config.ts +58 -0
- package/src/commands/config/index.ts +33 -0
- package/src/commands/dashboard/dashboard.ts +5 -0
- package/src/commands/dashboard/index.ts +6 -0
- package/src/commands/doctor/doctor.ts +271 -0
- package/src/commands/doctor/index.ts +25 -0
- package/src/commands/progress/index.ts +25 -0
- package/src/commands/progress/log.ts +64 -0
- package/src/commands/progress/show.ts +14 -0
- package/src/commands/project/add.ts +336 -0
- package/src/commands/project/index.ts +104 -0
- package/src/commands/project/list.ts +31 -0
- package/src/commands/project/remove.ts +43 -0
- package/src/commands/project/repo.ts +118 -0
- package/src/commands/project/show.ts +49 -0
- package/src/commands/sprint/close.ts +180 -0
- package/src/commands/sprint/context.ts +109 -0
- package/src/commands/sprint/create.ts +60 -0
- package/src/commands/sprint/current.ts +75 -0
- package/src/commands/sprint/delete.ts +72 -0
- package/src/commands/sprint/health.ts +229 -0
- package/src/commands/sprint/ideate.ts +496 -0
- package/src/commands/sprint/index.ts +226 -0
- package/src/commands/sprint/list.ts +86 -0
- package/src/commands/sprint/plan-utils.ts +207 -0
- package/src/commands/sprint/plan.ts +549 -0
- package/src/commands/sprint/refine.ts +359 -0
- package/src/commands/sprint/requirements.ts +58 -0
- package/src/commands/sprint/show.ts +140 -0
- package/src/commands/sprint/start.ts +119 -0
- package/src/commands/sprint/switch.ts +20 -0
- package/src/commands/task/add.ts +316 -0
- package/src/commands/task/import.ts +150 -0
- package/src/commands/task/index.ts +123 -0
- package/src/commands/task/list.ts +145 -0
- package/src/commands/task/next.ts +45 -0
- package/src/commands/task/remove.ts +47 -0
- package/src/commands/task/reorder.ts +45 -0
- package/src/commands/task/show.ts +111 -0
- package/src/commands/task/status.ts +99 -0
- package/src/commands/ticket/add.ts +265 -0
- package/src/commands/ticket/edit.ts +166 -0
- package/src/commands/ticket/index.ts +114 -0
- package/src/commands/ticket/list.ts +128 -0
- package/src/commands/ticket/refine-utils.ts +89 -0
- package/src/commands/ticket/refine.ts +268 -0
- package/src/commands/ticket/remove.ts +48 -0
- package/src/commands/ticket/show.ts +74 -0
- package/src/completion/handle.ts +30 -0
- package/src/completion/resolver.ts +241 -0
- package/src/interactive/dashboard.ts +268 -0
- package/src/interactive/escapable.ts +81 -0
- package/src/interactive/file-browser.ts +153 -0
- package/src/interactive/index.ts +429 -0
- package/src/interactive/menu.ts +403 -0
- package/src/interactive/selectors.ts +273 -0
- package/src/interactive/wizard.ts +221 -0
- package/src/providers/claude.ts +53 -0
- package/src/providers/copilot.ts +86 -0
- package/src/providers/index.ts +43 -0
- package/src/providers/types.ts +85 -0
- package/src/schemas/index.ts +130 -0
- package/src/store/config.ts +74 -0
- package/src/store/progress.ts +230 -0
- package/src/store/project.ts +276 -0
- package/src/store/sprint.ts +229 -0
- package/src/store/task.ts +443 -0
- package/src/store/ticket.ts +178 -0
- package/src/theme/index.ts +215 -0
- package/src/theme/ui.ts +872 -0
- package/src/utils/detect-scripts.ts +247 -0
- package/src/utils/editor-input.ts +41 -0
- package/src/utils/editor.ts +37 -0
- package/src/utils/exit-codes.ts +27 -0
- package/src/utils/file-lock.ts +135 -0
- package/src/utils/git.ts +185 -0
- package/src/utils/ids.ts +37 -0
- package/src/utils/issue-fetch.ts +244 -0
- package/src/utils/json-extract.ts +62 -0
- package/src/utils/multiline.ts +61 -0
- package/src/utils/path-selector.ts +236 -0
- package/src/utils/paths.ts +108 -0
- package/src/utils/provider.ts +34 -0
- package/src/utils/requirements-export.ts +63 -0
- package/src/utils/storage.ts +107 -0
- package/tsconfig.json +25 -0
package/src/ai/runner.ts
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { confirm, input, select } from '@inquirer/prompts';
|
|
2
|
+
import { log, printHeader, showError, showRandomQuote, showSuccess, showWarning, terminalBell } from '@src/theme/ui.ts';
|
|
3
|
+
import {
|
|
4
|
+
activateSprint,
|
|
5
|
+
assertSprintStatus,
|
|
6
|
+
closeSprint,
|
|
7
|
+
getSprint,
|
|
8
|
+
resolveSprintId,
|
|
9
|
+
saveSprint,
|
|
10
|
+
} from '@src/store/sprint.ts';
|
|
11
|
+
import {
|
|
12
|
+
areAllTasksDone,
|
|
13
|
+
DependencyCycleError,
|
|
14
|
+
getRemainingTasks,
|
|
15
|
+
getTasks,
|
|
16
|
+
reorderByDependencies,
|
|
17
|
+
} from '@src/store/task.ts';
|
|
18
|
+
import { formatTicketId, getPendingRequirements } from '@src/store/ticket.ts';
|
|
19
|
+
import {
|
|
20
|
+
executeTaskLoop,
|
|
21
|
+
executeTaskLoopParallel,
|
|
22
|
+
type ExecutionSummary,
|
|
23
|
+
type ExecutorOptions,
|
|
24
|
+
} from '@src/ai/executor.ts';
|
|
25
|
+
import {
|
|
26
|
+
getEffectiveCheckScript,
|
|
27
|
+
getProjectForTask,
|
|
28
|
+
type CheckResults,
|
|
29
|
+
type CheckStatus,
|
|
30
|
+
} from '@src/ai/task-context.ts';
|
|
31
|
+
import { runLifecycleHook } from '@src/ai/lifecycle.ts';
|
|
32
|
+
import type { Sprint } from '@src/schemas/index.ts';
|
|
33
|
+
import {
|
|
34
|
+
createAndCheckoutBranch,
|
|
35
|
+
generateBranchName,
|
|
36
|
+
getCurrentBranch,
|
|
37
|
+
hasUncommittedChanges,
|
|
38
|
+
isValidBranchName,
|
|
39
|
+
verifyCurrentBranch,
|
|
40
|
+
} from '@src/utils/git.ts';
|
|
41
|
+
|
|
42
|
+
// Re-export types for convenience
|
|
43
|
+
export type { ExecutorOptions, ExecutionSummary } from '@src/ai/executor.ts';
|
|
44
|
+
|
|
45
|
+
// Alias for backward compatibility
|
|
46
|
+
export type RunnerOptions = ExecutorOptions;
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// BRANCH MANAGEMENT
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Prompt the user to select a branch strategy for sprint execution.
|
|
54
|
+
* Returns the branch name to use, or null for no branch management.
|
|
55
|
+
*/
|
|
56
|
+
export async function promptBranchStrategy(sprintId: string): Promise<string | null> {
|
|
57
|
+
const autoBranch = generateBranchName(sprintId);
|
|
58
|
+
|
|
59
|
+
const strategy = await select({
|
|
60
|
+
message: 'How should this sprint manage branches?',
|
|
61
|
+
choices: [
|
|
62
|
+
{
|
|
63
|
+
name: `Create sprint branch: ${autoBranch} (Recommended)`,
|
|
64
|
+
value: 'auto',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'Keep current branch (no branch management)',
|
|
68
|
+
value: 'keep',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'Custom branch name',
|
|
72
|
+
value: 'custom',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (strategy === 'keep') return null;
|
|
78
|
+
if (strategy === 'auto') return autoBranch;
|
|
79
|
+
|
|
80
|
+
// Custom branch name
|
|
81
|
+
const customName = await input({
|
|
82
|
+
message: 'Enter branch name:',
|
|
83
|
+
validate: (value) => {
|
|
84
|
+
if (!value.trim()) return 'Branch name cannot be empty';
|
|
85
|
+
if (!isValidBranchName(value.trim())) {
|
|
86
|
+
return 'Invalid branch name. Use alphanumeric characters, hyphens, underscores, dots, and slashes.';
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return customName.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the branch to use for sprint execution.
|
|
97
|
+
*
|
|
98
|
+
* Priority:
|
|
99
|
+
* 1. options.branchName — explicit CLI override
|
|
100
|
+
* 2. options.branch — auto-generate from sprint ID
|
|
101
|
+
* 3. sprint.branch — saved from previous run (resume)
|
|
102
|
+
* 4. Interactive prompt — first run without flags
|
|
103
|
+
*
|
|
104
|
+
* Returns the branch name or null (no branch management).
|
|
105
|
+
*/
|
|
106
|
+
export async function resolveBranch(
|
|
107
|
+
sprintId: string,
|
|
108
|
+
sprint: Sprint,
|
|
109
|
+
options: ExecutorOptions
|
|
110
|
+
): Promise<string | null> {
|
|
111
|
+
if (options.branchName) return options.branchName;
|
|
112
|
+
if (options.branch) return generateBranchName(sprintId);
|
|
113
|
+
if (sprint.branch) return sprint.branch;
|
|
114
|
+
return promptBranchStrategy(sprintId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create/checkout the sprint branch in every repo that has remaining tasks.
|
|
119
|
+
*
|
|
120
|
+
* - Collects unique projectPath values from remaining tasks
|
|
121
|
+
* - Fails fast if any repo has uncommitted changes
|
|
122
|
+
* - Creates or checks out the branch (idempotent for resume)
|
|
123
|
+
* - Persists sprint.branch for subsequent runs
|
|
124
|
+
*/
|
|
125
|
+
export async function ensureSprintBranches(sprintId: string, sprint: Sprint, branchName: string): Promise<void> {
|
|
126
|
+
if (!isValidBranchName(branchName)) {
|
|
127
|
+
throw new Error(`Invalid branch name: ${branchName}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const tasks = await getTasks(sprintId);
|
|
131
|
+
const remainingTasks = tasks.filter((t) => t.status !== 'done');
|
|
132
|
+
const uniquePaths = [...new Set(remainingTasks.map((t) => t.projectPath))];
|
|
133
|
+
|
|
134
|
+
if (uniquePaths.length === 0) return;
|
|
135
|
+
|
|
136
|
+
// Check for uncommitted changes in all repos first (fail-fast)
|
|
137
|
+
for (const projectPath of uniquePaths) {
|
|
138
|
+
try {
|
|
139
|
+
if (hasUncommittedChanges(projectPath)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Repository at ${projectPath} has uncommitted changes. ` + 'Commit or stash them before starting the sprint.'
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err instanceof Error && err.message.includes('uncommitted changes')) {
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
// Not a git repo or other git error — skip with notice
|
|
149
|
+
log.dim(` Skipping ${projectPath} — not a git repository`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Create/checkout branch in each repo
|
|
155
|
+
for (const projectPath of uniquePaths) {
|
|
156
|
+
try {
|
|
157
|
+
const currentBranch = getCurrentBranch(projectPath);
|
|
158
|
+
if (currentBranch === branchName) {
|
|
159
|
+
log.dim(` Already on branch '${branchName}' in ${projectPath}`);
|
|
160
|
+
} else {
|
|
161
|
+
createAndCheckoutBranch(projectPath, branchName);
|
|
162
|
+
log.success(` Branch '${branchName}' ready in ${projectPath}`);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Failed to create branch '${branchName}' in ${projectPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
167
|
+
{ cause: err }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Persist the branch name
|
|
173
|
+
if (sprint.branch !== branchName) {
|
|
174
|
+
sprint.branch = branchName;
|
|
175
|
+
await saveSprint(sprint);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Verify a repo is on the expected sprint branch before task execution.
|
|
181
|
+
* Attempts auto-recovery via checkout if on wrong branch.
|
|
182
|
+
*
|
|
183
|
+
* @returns true if on correct branch, false if recovery failed
|
|
184
|
+
*/
|
|
185
|
+
export function verifySprintBranch(projectPath: string, expectedBranch: string): boolean {
|
|
186
|
+
try {
|
|
187
|
+
if (verifyCurrentBranch(projectPath, expectedBranch)) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Attempt auto-recovery
|
|
192
|
+
log.dim(` Branch mismatch in ${projectPath} — checking out '${expectedBranch}'`);
|
|
193
|
+
createAndCheckoutBranch(projectPath, expectedBranch);
|
|
194
|
+
return verifyCurrentBranch(projectPath, expectedBranch);
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ============================================================================
|
|
201
|
+
// CHECK SCRIPT EXECUTION
|
|
202
|
+
// ============================================================================
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run checkScript for every unique projectPath that has remaining tasks.
|
|
206
|
+
*
|
|
207
|
+
* This is "stage zero" — the environment must be ready before any AI agent
|
|
208
|
+
* starts work (aligned with the Anthropic effective-harnesses article).
|
|
209
|
+
*
|
|
210
|
+
* Design notes:
|
|
211
|
+
* - Check tracking: timestamps recorded in sprint.checkRanAt so re-runs skip
|
|
212
|
+
* already-completed checks (idempotent resume). Use refreshCheck to force.
|
|
213
|
+
* - Fail-fast on multi-repo — partial setup is worse than no setup, so we abort
|
|
214
|
+
* on first failure rather than continuing with an inconsistent environment
|
|
215
|
+
* - Repos without a configured check script are skipped with a dim warning
|
|
216
|
+
* - Returns a CheckResults map so the executor can inform each AI agent what ran
|
|
217
|
+
*
|
|
218
|
+
* @returns { success, results } — results maps projectPath → CheckStatus
|
|
219
|
+
*/
|
|
220
|
+
export async function runCheckScripts(
|
|
221
|
+
sprintId: string,
|
|
222
|
+
sprint: Sprint,
|
|
223
|
+
refreshCheck = false
|
|
224
|
+
): Promise<{ success: true; results: CheckResults } | { success: false; error: string }> {
|
|
225
|
+
const results: CheckResults = new Map();
|
|
226
|
+
const tasks = await getTasks(sprintId);
|
|
227
|
+
const remainingTasks = tasks.filter((t) => t.status !== 'done');
|
|
228
|
+
|
|
229
|
+
// Collect unique project paths from remaining tasks
|
|
230
|
+
const uniquePaths = [...new Set(remainingTasks.map((t) => t.projectPath))];
|
|
231
|
+
|
|
232
|
+
if (uniquePaths.length === 0) {
|
|
233
|
+
return { success: true, results };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const projectPath of uniquePaths) {
|
|
237
|
+
// Find a representative task for this path so we can look up its project
|
|
238
|
+
const taskForPath = remainingTasks.find((t) => t.projectPath === projectPath);
|
|
239
|
+
if (!taskForPath) continue;
|
|
240
|
+
|
|
241
|
+
const project = await getProjectForTask(taskForPath, sprint);
|
|
242
|
+
|
|
243
|
+
// Check scripts come from explicit repo config only — no runtime auto-detection.
|
|
244
|
+
// Heuristic detection is used as suggestions during `project add` / `project repo add`.
|
|
245
|
+
const checkScript = getEffectiveCheckScript(project, projectPath);
|
|
246
|
+
const repo = project?.repositories.find((r) => r.path === projectPath);
|
|
247
|
+
const repoName = repo?.name ?? projectPath;
|
|
248
|
+
|
|
249
|
+
if (!checkScript) {
|
|
250
|
+
log.dim(` No check script for ${repoName} — configure via 'project add'`);
|
|
251
|
+
results.set(projectPath, { ran: false, reason: 'no-script' } satisfies CheckStatus);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check if already ran this sprint (skip unless --refresh-check)
|
|
256
|
+
const previousRun = sprint.checkRanAt[projectPath];
|
|
257
|
+
if (previousRun && !refreshCheck) {
|
|
258
|
+
log.dim(` Check already ran for ${repoName} at ${previousRun} — skipping`);
|
|
259
|
+
results.set(projectPath, { ran: true, script: checkScript } satisfies CheckStatus);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
log.info(`\nRunning check for ${repoName}: ${checkScript}`);
|
|
264
|
+
|
|
265
|
+
const hookResult = runLifecycleHook(projectPath, checkScript, 'sprintStart');
|
|
266
|
+
|
|
267
|
+
if (!hookResult.passed) {
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
error: `Check failed for ${repoName}: ${checkScript}\n${hookResult.output}`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Record timestamp per-repo (persisted immediately so partial failures are safe)
|
|
275
|
+
sprint.checkRanAt[projectPath] = new Date().toISOString();
|
|
276
|
+
await saveSprint(sprint);
|
|
277
|
+
|
|
278
|
+
log.success(`Check complete: ${repoName}`);
|
|
279
|
+
results.set(projectPath, { ran: true, script: checkScript } satisfies CheckStatus);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { success: true, results };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Determine if execution should use parallel mode.
|
|
287
|
+
* Forces sequential for session mode, step mode, or explicit --concurrency 1.
|
|
288
|
+
*/
|
|
289
|
+
function shouldRunParallel(options: ExecutorOptions): boolean {
|
|
290
|
+
if (options.session) return false;
|
|
291
|
+
if (options.step) return false;
|
|
292
|
+
if (options.concurrency === 1) return false;
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Run sprint execution with lifecycle management.
|
|
298
|
+
* Handles sprint activation, dependency reordering, execution, and closing.
|
|
299
|
+
*/
|
|
300
|
+
export async function runSprint(
|
|
301
|
+
sprintId: string | undefined,
|
|
302
|
+
options: ExecutorOptions
|
|
303
|
+
): Promise<ExecutionSummary | undefined> {
|
|
304
|
+
const id = await resolveSprintId(sprintId);
|
|
305
|
+
let sprint = await getSprint(id);
|
|
306
|
+
|
|
307
|
+
// Precondition: warn if draft sprint has unrefined tickets
|
|
308
|
+
if (sprint.status === 'draft' && !options.force) {
|
|
309
|
+
const unrefinedTickets = getPendingRequirements(sprint.tickets);
|
|
310
|
+
if (unrefinedTickets.length > 0) {
|
|
311
|
+
showWarning(
|
|
312
|
+
`Sprint has ${String(unrefinedTickets.length)} unrefined ticket${unrefinedTickets.length !== 1 ? 's' : ''}:`
|
|
313
|
+
);
|
|
314
|
+
for (const ticket of unrefinedTickets) {
|
|
315
|
+
log.item(`${formatTicketId(ticket)} \u2014 ${ticket.title}`);
|
|
316
|
+
}
|
|
317
|
+
log.newline();
|
|
318
|
+
|
|
319
|
+
const shouldContinue = await confirm({
|
|
320
|
+
message: 'Start anyway without refining?',
|
|
321
|
+
default: false,
|
|
322
|
+
});
|
|
323
|
+
if (!shouldContinue) {
|
|
324
|
+
log.dim("Run 'sprint refine' first, or use --force to skip this check.");
|
|
325
|
+
log.newline();
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Precondition: block activation if draft sprint has approved tickets without tasks
|
|
332
|
+
if (sprint.status === 'draft' && !options.force) {
|
|
333
|
+
const tasks = await getTasks(id);
|
|
334
|
+
const ticketIdsWithTasks = new Set(tasks.map((t) => t.ticketId).filter(Boolean));
|
|
335
|
+
const unplannedTickets = sprint.tickets.filter(
|
|
336
|
+
(t) => t.requirementStatus === 'approved' && !ticketIdsWithTasks.has(t.id)
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
if (unplannedTickets.length > 0) {
|
|
340
|
+
showWarning('Sprint has refined tickets with no planned tasks:');
|
|
341
|
+
for (const ticket of unplannedTickets) {
|
|
342
|
+
log.item(`${formatTicketId(ticket)} \u2014 ${ticket.title}`);
|
|
343
|
+
}
|
|
344
|
+
log.newline();
|
|
345
|
+
|
|
346
|
+
const shouldContinue = await confirm({
|
|
347
|
+
message: 'Start anyway without planning?',
|
|
348
|
+
default: false,
|
|
349
|
+
});
|
|
350
|
+
if (!shouldContinue) {
|
|
351
|
+
log.dim("Run 'sprint plan' first, or use --force to skip this check.");
|
|
352
|
+
log.newline();
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Resolve branch strategy before activation (prompt while still interactable)
|
|
359
|
+
const branchName = await resolveBranch(id, sprint, options);
|
|
360
|
+
|
|
361
|
+
// Auto-activate if sprint is draft
|
|
362
|
+
if (sprint.status === 'draft') {
|
|
363
|
+
sprint = await activateSprint(id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Validate sprint is active
|
|
367
|
+
assertSprintStatus(sprint, ['active'], 'start');
|
|
368
|
+
|
|
369
|
+
printHeader('Sprint Start');
|
|
370
|
+
log.info(`Sprint: ${sprint.name}`);
|
|
371
|
+
log.info(`ID: ${sprint.id}`);
|
|
372
|
+
|
|
373
|
+
const modes: string[] = [];
|
|
374
|
+
if (options.session) {
|
|
375
|
+
modes.push('session');
|
|
376
|
+
} else {
|
|
377
|
+
modes.push('headless');
|
|
378
|
+
}
|
|
379
|
+
if (options.step) {
|
|
380
|
+
modes.push('step-by-step');
|
|
381
|
+
}
|
|
382
|
+
if (options.noCommit) {
|
|
383
|
+
modes.push('no-commit');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const parallel = shouldRunParallel(options);
|
|
387
|
+
if (parallel) {
|
|
388
|
+
modes.push('parallel');
|
|
389
|
+
}
|
|
390
|
+
log.dim(`Mode: ${modes.join(', ')}`);
|
|
391
|
+
if (options.count) {
|
|
392
|
+
log.dim(`Limit: ${String(options.count)} task(s)`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Display branch info
|
|
396
|
+
if (branchName) {
|
|
397
|
+
log.info(`Branch: ${branchName}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Ensure sprint branches are created/checked out in all repos
|
|
401
|
+
if (branchName) {
|
|
402
|
+
try {
|
|
403
|
+
await ensureSprintBranches(id, sprint, branchName);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
log.newline();
|
|
406
|
+
showError(err instanceof Error ? err.message : String(err));
|
|
407
|
+
log.newline();
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Reorder tasks by dependencies
|
|
413
|
+
try {
|
|
414
|
+
await reorderByDependencies(id);
|
|
415
|
+
log.dim('Tasks reordered by dependencies');
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (err instanceof DependencyCycleError) {
|
|
418
|
+
log.newline();
|
|
419
|
+
showWarning(err.message);
|
|
420
|
+
log.dim('Fix the dependency cycle before starting.');
|
|
421
|
+
log.newline();
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Stage zero: run check scripts for all repositories
|
|
428
|
+
const checkResult = await runCheckScripts(id, sprint, options.refreshCheck);
|
|
429
|
+
if (!checkResult.success) {
|
|
430
|
+
log.newline();
|
|
431
|
+
showError(checkResult.error);
|
|
432
|
+
log.newline();
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Execute the task loop (parallel or sequential)
|
|
437
|
+
const summary = parallel
|
|
438
|
+
? await executeTaskLoopParallel(id, options, checkResult.results)
|
|
439
|
+
: await executeTaskLoop(id, options, checkResult.results);
|
|
440
|
+
|
|
441
|
+
// Print summary
|
|
442
|
+
printHeader('Summary');
|
|
443
|
+
log.info(`Completed: ${String(summary.completed)} task(s)`);
|
|
444
|
+
log.info(`Remaining: ${String(summary.remaining)} task(s)`);
|
|
445
|
+
|
|
446
|
+
// Handle sprint closing for fully completed sprints
|
|
447
|
+
if (await areAllTasksDone(id)) {
|
|
448
|
+
terminalBell();
|
|
449
|
+
showSuccess('All tasks in sprint are done!');
|
|
450
|
+
showRandomQuote();
|
|
451
|
+
const shouldClose = await confirm({
|
|
452
|
+
message: 'Close the sprint?',
|
|
453
|
+
default: true,
|
|
454
|
+
});
|
|
455
|
+
if (shouldClose) {
|
|
456
|
+
await closeSprint(id);
|
|
457
|
+
showSuccess(`Sprint closed: ${id}`);
|
|
458
|
+
}
|
|
459
|
+
} else if (summary.stopReason === 'all_blocked') {
|
|
460
|
+
log.newline();
|
|
461
|
+
showWarning('All remaining tasks are blocked by dependencies.');
|
|
462
|
+
const remaining = await getRemainingTasks(id);
|
|
463
|
+
const blockedTasks = remaining.filter((t) => t.blockedBy.length > 0);
|
|
464
|
+
if (blockedTasks.length > 0) {
|
|
465
|
+
log.dim('Blocked tasks:');
|
|
466
|
+
for (const t of blockedTasks.slice(0, 5)) {
|
|
467
|
+
log.item(`${t.name} (blocked by: ${t.blockedBy.join(', ')})`);
|
|
468
|
+
}
|
|
469
|
+
if (blockedTasks.length > 5) {
|
|
470
|
+
log.dim(` ... and ${String(blockedTasks.length - 5)} more`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
log.newline();
|
|
476
|
+
|
|
477
|
+
return summary;
|
|
478
|
+
}
|