ralphctl 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -24
- package/dist/add-HGJCLWED.mjs +14 -0
- package/dist/add-MRGCS3US.mjs +14 -0
- package/dist/chunk-6PYTKGB5.mjs +316 -0
- package/dist/chunk-7TG3EAQ2.mjs +20 -0
- package/dist/chunk-EKMZZRWI.mjs +521 -0
- package/dist/chunk-JON4GCLR.mjs +59 -0
- package/dist/chunk-LOR7QBXX.mjs +3683 -0
- package/dist/chunk-MNMQC36F.mjs +556 -0
- package/dist/chunk-MRKOFVTM.mjs +537 -0
- package/dist/chunk-NTWO2LXB.mjs +52 -0
- package/dist/chunk-QBXHAXHI.mjs +562 -0
- package/dist/chunk-WGHJI3OI.mjs +214 -0
- package/dist/cli.mjs +4245 -0
- package/dist/create-MG7E7PLQ.mjs +10 -0
- package/dist/handle-UG5M2OON.mjs +22 -0
- package/dist/multiline-OHSNFCRG.mjs +40 -0
- package/dist/project-NT3L4FTB.mjs +28 -0
- package/dist/resolver-WSFWKACM.mjs +153 -0
- package/dist/sprint-4VHDLGFN.mjs +37 -0
- package/dist/wizard-LRELAN2J.mjs +196 -0
- package/package.json +19 -28
- package/CHANGELOG.md +0 -94
- package/bin/ralphctl +0 -13
- package/src/ai/executor.ts +0 -973
- package/src/ai/lifecycle.ts +0 -45
- package/src/ai/parser.ts +0 -40
- package/src/ai/permissions.ts +0 -207
- package/src/ai/process-manager.ts +0 -248
- package/src/ai/prompts/index.ts +0 -89
- package/src/ai/rate-limiter.ts +0 -89
- package/src/ai/runner.ts +0 -478
- package/src/ai/session.ts +0 -319
- package/src/ai/task-context.ts +0 -270
- package/src/cli-metadata.ts +0 -7
- package/src/cli.ts +0 -65
- package/src/commands/completion/index.ts +0 -33
- package/src/commands/config/config.ts +0 -58
- package/src/commands/config/index.ts +0 -33
- package/src/commands/dashboard/dashboard.ts +0 -5
- package/src/commands/dashboard/index.ts +0 -6
- package/src/commands/doctor/doctor.ts +0 -271
- package/src/commands/doctor/index.ts +0 -25
- package/src/commands/progress/index.ts +0 -25
- package/src/commands/progress/log.ts +0 -64
- package/src/commands/progress/show.ts +0 -14
- package/src/commands/project/add.ts +0 -336
- package/src/commands/project/index.ts +0 -104
- package/src/commands/project/list.ts +0 -31
- package/src/commands/project/remove.ts +0 -43
- package/src/commands/project/repo.ts +0 -118
- package/src/commands/project/show.ts +0 -49
- package/src/commands/sprint/close.ts +0 -180
- package/src/commands/sprint/context.ts +0 -109
- package/src/commands/sprint/create.ts +0 -60
- package/src/commands/sprint/current.ts +0 -75
- package/src/commands/sprint/delete.ts +0 -72
- package/src/commands/sprint/health.ts +0 -229
- package/src/commands/sprint/ideate.ts +0 -496
- package/src/commands/sprint/index.ts +0 -226
- package/src/commands/sprint/list.ts +0 -86
- package/src/commands/sprint/plan-utils.ts +0 -207
- package/src/commands/sprint/plan.ts +0 -549
- package/src/commands/sprint/refine.ts +0 -359
- package/src/commands/sprint/requirements.ts +0 -58
- package/src/commands/sprint/show.ts +0 -140
- package/src/commands/sprint/start.ts +0 -119
- package/src/commands/sprint/switch.ts +0 -20
- package/src/commands/task/add.ts +0 -316
- package/src/commands/task/import.ts +0 -150
- package/src/commands/task/index.ts +0 -123
- package/src/commands/task/list.ts +0 -145
- package/src/commands/task/next.ts +0 -45
- package/src/commands/task/remove.ts +0 -47
- package/src/commands/task/reorder.ts +0 -45
- package/src/commands/task/show.ts +0 -111
- package/src/commands/task/status.ts +0 -99
- package/src/commands/ticket/add.ts +0 -265
- package/src/commands/ticket/edit.ts +0 -166
- package/src/commands/ticket/index.ts +0 -114
- package/src/commands/ticket/list.ts +0 -128
- package/src/commands/ticket/refine-utils.ts +0 -89
- package/src/commands/ticket/refine.ts +0 -268
- package/src/commands/ticket/remove.ts +0 -48
- package/src/commands/ticket/show.ts +0 -74
- package/src/completion/handle.ts +0 -30
- package/src/completion/resolver.ts +0 -241
- package/src/interactive/dashboard.ts +0 -268
- package/src/interactive/escapable.ts +0 -81
- package/src/interactive/file-browser.ts +0 -153
- package/src/interactive/index.ts +0 -429
- package/src/interactive/menu.ts +0 -403
- package/src/interactive/selectors.ts +0 -273
- package/src/interactive/wizard.ts +0 -221
- package/src/providers/claude.ts +0 -53
- package/src/providers/copilot.ts +0 -86
- package/src/providers/index.ts +0 -43
- package/src/providers/types.ts +0 -85
- package/src/schemas/index.ts +0 -130
- package/src/store/config.ts +0 -74
- package/src/store/progress.ts +0 -230
- package/src/store/project.ts +0 -276
- package/src/store/sprint.ts +0 -229
- package/src/store/task.ts +0 -443
- package/src/store/ticket.ts +0 -178
- package/src/theme/index.ts +0 -215
- package/src/theme/ui.ts +0 -872
- package/src/utils/detect-scripts.ts +0 -247
- package/src/utils/editor-input.ts +0 -41
- package/src/utils/editor.ts +0 -37
- package/src/utils/exit-codes.ts +0 -27
- package/src/utils/file-lock.ts +0 -135
- package/src/utils/git.ts +0 -185
- package/src/utils/ids.ts +0 -37
- package/src/utils/issue-fetch.ts +0 -244
- package/src/utils/json-extract.ts +0 -62
- package/src/utils/multiline.ts +0 -61
- package/src/utils/path-selector.ts +0 -236
- package/src/utils/paths.ts +0 -108
- package/src/utils/provider.ts +0 -34
- package/src/utils/requirements-export.ts +0 -63
- package/src/utils/storage.ts +0 -107
- package/tsconfig.json +0 -25
- /package/{src/ai → dist}/prompts/ideate-auto.md +0 -0
- /package/{src/ai → dist}/prompts/ideate.md +0 -0
- /package/{src/ai → dist}/prompts/plan-auto.md +0 -0
- /package/{src/ai → dist}/prompts/plan-common.md +0 -0
- /package/{src/ai → dist}/prompts/plan-interactive.md +0 -0
- /package/{src/ai → dist}/prompts/task-execution.md +0 -0
- /package/{src/ai → dist}/prompts/ticket-refine.md +0 -0
package/src/store/sprint.ts
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getProgressFilePath,
|
|
3
|
-
getSprintDir,
|
|
4
|
-
getSprintFilePath,
|
|
5
|
-
getSprintsDir,
|
|
6
|
-
getTasksFilePath,
|
|
7
|
-
} from '@src/utils/paths.ts';
|
|
8
|
-
import {
|
|
9
|
-
appendToFile,
|
|
10
|
-
ensureDir,
|
|
11
|
-
fileExists,
|
|
12
|
-
listDirs,
|
|
13
|
-
readValidatedJson,
|
|
14
|
-
removeDir,
|
|
15
|
-
ValidationError,
|
|
16
|
-
writeValidatedJson,
|
|
17
|
-
} from '@src/utils/storage.ts';
|
|
18
|
-
import { type Sprint, SprintSchema, type SprintStatus, type Tasks, TasksSchema } from '@src/schemas/index.ts';
|
|
19
|
-
import { getCurrentSprint } from '@src/store/config.ts';
|
|
20
|
-
import { generateSprintId } from '@src/utils/ids.ts';
|
|
21
|
-
import { logBaselines } from '@src/store/progress.ts';
|
|
22
|
-
|
|
23
|
-
export class SprintNotFoundError extends Error {
|
|
24
|
-
public readonly sprintId: string;
|
|
25
|
-
|
|
26
|
-
constructor(sprintId: string) {
|
|
27
|
-
super(`Sprint not found: ${sprintId}`);
|
|
28
|
-
this.name = 'SprintNotFoundError';
|
|
29
|
-
this.sprintId = sprintId;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export class SprintStatusError extends Error {
|
|
34
|
-
public readonly currentStatus: SprintStatus;
|
|
35
|
-
public readonly operation: string;
|
|
36
|
-
|
|
37
|
-
constructor(message: string, currentStatus: SprintStatus, operation: string) {
|
|
38
|
-
super(message);
|
|
39
|
-
this.name = 'SprintStatusError';
|
|
40
|
-
this.currentStatus = currentStatus;
|
|
41
|
-
this.operation = operation;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class NoCurrentSprintError extends Error {
|
|
46
|
-
constructor() {
|
|
47
|
-
super('No sprint specified and no current sprint set.');
|
|
48
|
-
this.name = 'NoCurrentSprintError';
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Assert that a sprint is in one of the allowed statuses for an operation.
|
|
54
|
-
* @throws SprintStatusError if status is not allowed
|
|
55
|
-
*/
|
|
56
|
-
export function assertSprintStatus(
|
|
57
|
-
sprint: Sprint,
|
|
58
|
-
allowedStatuses: SprintStatus[],
|
|
59
|
-
operation: string
|
|
60
|
-
): asserts sprint is Sprint {
|
|
61
|
-
if (!allowedStatuses.includes(sprint.status)) {
|
|
62
|
-
const statusText = allowedStatuses.join(' or ');
|
|
63
|
-
const hints: Record<string, string> = {
|
|
64
|
-
'add tickets': 'Close the current sprint and create a new one for additional work.',
|
|
65
|
-
'remove tickets': 'Sprint must be in draft status to remove tickets.',
|
|
66
|
-
'add tasks': 'Close the current sprint and create a new one for additional work.',
|
|
67
|
-
'remove tasks': 'Sprint must be in draft status to remove tasks.',
|
|
68
|
-
'reorder tasks': 'Sprint must be in draft status to reorder tasks.',
|
|
69
|
-
refine: 'Refinement can only be done on draft sprints.',
|
|
70
|
-
plan: 'Planning can only be done on draft sprints.',
|
|
71
|
-
activate: 'Sprint must be in draft status to activate.',
|
|
72
|
-
start: 'Sprint must be draft or active to start.',
|
|
73
|
-
'update task status': 'Task status can only be updated during active execution.',
|
|
74
|
-
'log progress': 'Progress can only be logged during active execution.',
|
|
75
|
-
close: 'Sprint must be active to close.',
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const hint = hints[operation] ?? '';
|
|
79
|
-
const hintText = hint ? `\nHint: ${hint}` : '';
|
|
80
|
-
|
|
81
|
-
throw new SprintStatusError(
|
|
82
|
-
`Cannot ${operation}: sprint status is '${sprint.status}' (must be ${statusText}).${hintText}`,
|
|
83
|
-
sprint.status,
|
|
84
|
-
operation
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function createSprint(name?: string): Promise<Sprint> {
|
|
90
|
-
const id = generateSprintId(name);
|
|
91
|
-
const now = new Date().toISOString();
|
|
92
|
-
|
|
93
|
-
// Use the slug portion of the ID as display name if no name provided
|
|
94
|
-
const displayName = name ?? id.slice(16); // Skip "YYYYMMDD-HHmmss-" prefix
|
|
95
|
-
|
|
96
|
-
const sprint: Sprint = {
|
|
97
|
-
id,
|
|
98
|
-
name: displayName,
|
|
99
|
-
status: 'draft',
|
|
100
|
-
createdAt: now,
|
|
101
|
-
activatedAt: null,
|
|
102
|
-
closedAt: null,
|
|
103
|
-
tickets: [],
|
|
104
|
-
checkRanAt: {},
|
|
105
|
-
branch: null,
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const sprintDir = getSprintDir(id);
|
|
109
|
-
await ensureDir(sprintDir);
|
|
110
|
-
|
|
111
|
-
await writeValidatedJson(getSprintFilePath(id), sprint, SprintSchema);
|
|
112
|
-
await writeValidatedJson(getTasksFilePath(id), [], TasksSchema);
|
|
113
|
-
await appendToFile(getProgressFilePath(id), `# Sprint: ${displayName}\n\nCreated: ${now}\n\n---\n\n`);
|
|
114
|
-
|
|
115
|
-
return sprint;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Find the sprint with status='active' (if any).
|
|
120
|
-
* Returns null if no sprint is currently active.
|
|
121
|
-
*/
|
|
122
|
-
export async function findActiveSprint(): Promise<Sprint | null> {
|
|
123
|
-
const sprints = await listSprints();
|
|
124
|
-
return sprints.find((s) => s.status === 'active') ?? null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export async function getSprint(sprintId: string): Promise<Sprint> {
|
|
128
|
-
const sprintPath = getSprintFilePath(sprintId);
|
|
129
|
-
if (!(await fileExists(sprintPath))) {
|
|
130
|
-
throw new SprintNotFoundError(sprintId);
|
|
131
|
-
}
|
|
132
|
-
return readValidatedJson(sprintPath, SprintSchema);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export async function saveSprint(sprint: Sprint): Promise<void> {
|
|
136
|
-
await writeValidatedJson(getSprintFilePath(sprint.id), sprint, SprintSchema);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export async function listSprints(): Promise<Sprint[]> {
|
|
140
|
-
const sprintsDir = getSprintsDir();
|
|
141
|
-
const dirs = await listDirs(sprintsDir);
|
|
142
|
-
|
|
143
|
-
const sprints: Sprint[] = [];
|
|
144
|
-
for (const dir of dirs) {
|
|
145
|
-
try {
|
|
146
|
-
const sprint = await getSprint(dir);
|
|
147
|
-
sprints.push(sprint);
|
|
148
|
-
} catch (err) {
|
|
149
|
-
if (err instanceof ValidationError || err instanceof SprintNotFoundError) {
|
|
150
|
-
continue; // Skip invalid/corrupt sprint directories
|
|
151
|
-
}
|
|
152
|
-
throw err;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Sort by creation date (newest first)
|
|
157
|
-
return sprints.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export async function activateSprint(sprintId: string): Promise<Sprint> {
|
|
161
|
-
const sprint = await getSprint(sprintId);
|
|
162
|
-
|
|
163
|
-
assertSprintStatus(sprint, ['draft'], 'activate');
|
|
164
|
-
|
|
165
|
-
sprint.status = 'active';
|
|
166
|
-
sprint.activatedAt = new Date().toISOString();
|
|
167
|
-
await saveSprint(sprint);
|
|
168
|
-
|
|
169
|
-
// Log baseline git state for each unique project path
|
|
170
|
-
const tasks: Tasks = await readValidatedJson(getTasksFilePath(sprintId), TasksSchema);
|
|
171
|
-
const projectPaths = tasks.map((t) => t.projectPath).filter((p): p is string => !!p);
|
|
172
|
-
|
|
173
|
-
if (projectPaths.length > 0) {
|
|
174
|
-
await logBaselines({
|
|
175
|
-
sprintId,
|
|
176
|
-
sprintName: sprint.name,
|
|
177
|
-
projectPaths,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return sprint;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export async function closeSprint(sprintId: string): Promise<Sprint> {
|
|
185
|
-
const sprint = await getSprint(sprintId);
|
|
186
|
-
|
|
187
|
-
assertSprintStatus(sprint, ['active'], 'close');
|
|
188
|
-
|
|
189
|
-
sprint.status = 'closed';
|
|
190
|
-
sprint.closedAt = new Date().toISOString();
|
|
191
|
-
sprint.checkRanAt = {};
|
|
192
|
-
await saveSprint(sprint);
|
|
193
|
-
|
|
194
|
-
return sprint;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function deleteSprint(sprintId: string): Promise<Sprint> {
|
|
198
|
-
const sprint = await getSprint(sprintId);
|
|
199
|
-
const sprintDir = getSprintDir(sprintId);
|
|
200
|
-
await removeDir(sprintDir);
|
|
201
|
-
return sprint;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export async function getCurrentSprintOrThrow(): Promise<Sprint> {
|
|
205
|
-
const currentSprintId = await getCurrentSprint();
|
|
206
|
-
if (!currentSprintId) {
|
|
207
|
-
throw new Error('No current sprint. Use "ralphctl sprint create" to create one.');
|
|
208
|
-
}
|
|
209
|
-
return getSprint(currentSprintId);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export async function getActiveSprintOrThrow(): Promise<Sprint> {
|
|
213
|
-
const activeSprint = await findActiveSprint();
|
|
214
|
-
if (!activeSprint) {
|
|
215
|
-
throw new Error('No active sprint. Use "ralphctl sprint start" to start a draft sprint.');
|
|
216
|
-
}
|
|
217
|
-
return activeSprint;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export async function resolveSprintId(sprintId?: string): Promise<string> {
|
|
221
|
-
if (sprintId) {
|
|
222
|
-
return sprintId;
|
|
223
|
-
}
|
|
224
|
-
const currentSprintId = await getCurrentSprint();
|
|
225
|
-
if (!currentSprintId) {
|
|
226
|
-
throw new NoCurrentSprintError();
|
|
227
|
-
}
|
|
228
|
-
return currentSprintId;
|
|
229
|
-
}
|
package/src/store/task.ts
DELETED
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
import { getTasksFilePath } from '@src/utils/paths.ts';
|
|
2
|
-
import { readValidatedJson, writeValidatedJson } from '@src/utils/storage.ts';
|
|
3
|
-
import { type Task, type Tasks, TasksSchema, type TaskStatus } from '@src/schemas/index.ts';
|
|
4
|
-
import { assertSprintStatus, getSprint, resolveSprintId } from '@src/store/sprint.ts';
|
|
5
|
-
import { generateUuid8 } from '@src/utils/ids.ts';
|
|
6
|
-
import { withFileLock } from '@src/utils/file-lock.ts';
|
|
7
|
-
|
|
8
|
-
export class TaskNotFoundError extends Error {
|
|
9
|
-
public readonly taskId: string;
|
|
10
|
-
|
|
11
|
-
constructor(taskId: string) {
|
|
12
|
-
super(`Task not found: ${taskId}`);
|
|
13
|
-
this.name = 'TaskNotFoundError';
|
|
14
|
-
this.taskId = taskId;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class TaskStatusError extends Error {
|
|
19
|
-
constructor(message: string) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.name = 'TaskStatusError';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export class DependencyCycleError extends Error {
|
|
26
|
-
public readonly cycle: string[];
|
|
27
|
-
|
|
28
|
-
constructor(cycle: string[]) {
|
|
29
|
-
super(`Dependency cycle detected: ${cycle.join(' → ')}`);
|
|
30
|
-
this.name = 'DependencyCycleError';
|
|
31
|
-
this.cycle = cycle;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function getTasks(sprintId?: string): Promise<Tasks> {
|
|
36
|
-
const id = await resolveSprintId(sprintId);
|
|
37
|
-
return readValidatedJson(getTasksFilePath(id), TasksSchema);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function saveTasks(tasks: Tasks, sprintId?: string): Promise<void> {
|
|
41
|
-
const id = await resolveSprintId(sprintId);
|
|
42
|
-
await writeValidatedJson(getTasksFilePath(id), tasks, TasksSchema);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function getTask(taskId: string, sprintId?: string): Promise<Task> {
|
|
46
|
-
const tasks = await getTasks(sprintId);
|
|
47
|
-
const task = tasks.find((t) => t.id === taskId);
|
|
48
|
-
if (!task) {
|
|
49
|
-
throw new TaskNotFoundError(taskId);
|
|
50
|
-
}
|
|
51
|
-
return task;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface AddTaskInput {
|
|
55
|
-
name: string;
|
|
56
|
-
description?: string;
|
|
57
|
-
steps?: string[];
|
|
58
|
-
ticketId?: string;
|
|
59
|
-
blockedBy?: string[];
|
|
60
|
-
projectPath: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export async function addTask(input: AddTaskInput, sprintId?: string): Promise<Task> {
|
|
64
|
-
const id = await resolveSprintId(sprintId);
|
|
65
|
-
const sprint = await getSprint(id);
|
|
66
|
-
|
|
67
|
-
// Check sprint status - must be draft to add tasks
|
|
68
|
-
assertSprintStatus(sprint, ['draft'], 'add tasks');
|
|
69
|
-
|
|
70
|
-
const tasksFilePath = getTasksFilePath(id);
|
|
71
|
-
|
|
72
|
-
// Use file lock for atomic read-modify-write
|
|
73
|
-
return withFileLock(tasksFilePath, async () => {
|
|
74
|
-
const tasks = await getTasks(id);
|
|
75
|
-
const maxOrder = tasks.reduce((max, t) => Math.max(max, t.order), 0);
|
|
76
|
-
|
|
77
|
-
const task: Task = {
|
|
78
|
-
id: generateUuid8(),
|
|
79
|
-
name: input.name,
|
|
80
|
-
description: input.description,
|
|
81
|
-
steps: input.steps ?? [],
|
|
82
|
-
status: 'todo',
|
|
83
|
-
order: maxOrder + 1,
|
|
84
|
-
ticketId: input.ticketId,
|
|
85
|
-
blockedBy: input.blockedBy ?? [],
|
|
86
|
-
projectPath: input.projectPath,
|
|
87
|
-
verified: false,
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
tasks.push(task);
|
|
91
|
-
await saveTasks(tasks, id);
|
|
92
|
-
return task;
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export async function removeTask(taskId: string, sprintId?: string): Promise<void> {
|
|
97
|
-
const id = await resolveSprintId(sprintId);
|
|
98
|
-
const sprint = await getSprint(id);
|
|
99
|
-
|
|
100
|
-
// Check sprint status - must be draft to remove tasks
|
|
101
|
-
assertSprintStatus(sprint, ['draft'], 'remove tasks');
|
|
102
|
-
|
|
103
|
-
const tasksFilePath = getTasksFilePath(id);
|
|
104
|
-
|
|
105
|
-
// Use file lock for atomic read-modify-write
|
|
106
|
-
await withFileLock(tasksFilePath, async () => {
|
|
107
|
-
const tasks = await getTasks(id);
|
|
108
|
-
const index = tasks.findIndex((t) => t.id === taskId);
|
|
109
|
-
if (index === -1) {
|
|
110
|
-
throw new TaskNotFoundError(taskId);
|
|
111
|
-
}
|
|
112
|
-
tasks.splice(index, 1);
|
|
113
|
-
await saveTasks(tasks, id);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function updateTaskStatus(taskId: string, status: TaskStatus, sprintId?: string): Promise<Task> {
|
|
118
|
-
const id = await resolveSprintId(sprintId);
|
|
119
|
-
const sprint = await getSprint(id);
|
|
120
|
-
|
|
121
|
-
// Check sprint status - must be active to update task status
|
|
122
|
-
assertSprintStatus(sprint, ['active'], 'update task status');
|
|
123
|
-
|
|
124
|
-
const tasksFilePath = getTasksFilePath(id);
|
|
125
|
-
|
|
126
|
-
// Use file lock for atomic read-modify-write
|
|
127
|
-
return withFileLock(tasksFilePath, async () => {
|
|
128
|
-
const tasks = await getTasks(id);
|
|
129
|
-
const task = tasks.find((t) => t.id === taskId);
|
|
130
|
-
if (!task) {
|
|
131
|
-
throw new TaskNotFoundError(taskId);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
task.status = status;
|
|
135
|
-
await saveTasks(tasks, id);
|
|
136
|
-
return task;
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export interface UpdateTaskInput {
|
|
141
|
-
verified?: boolean;
|
|
142
|
-
verificationOutput?: string;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export async function updateTask(taskId: string, updates: UpdateTaskInput, sprintId?: string): Promise<Task> {
|
|
146
|
-
const id = await resolveSprintId(sprintId);
|
|
147
|
-
const sprint = await getSprint(id);
|
|
148
|
-
|
|
149
|
-
// Check sprint status - must be active to update task
|
|
150
|
-
assertSprintStatus(sprint, ['active'], 'update task');
|
|
151
|
-
|
|
152
|
-
const tasksFilePath = getTasksFilePath(id);
|
|
153
|
-
|
|
154
|
-
// Use file lock for atomic read-modify-write
|
|
155
|
-
return withFileLock(tasksFilePath, async () => {
|
|
156
|
-
const tasks = await getTasks(id);
|
|
157
|
-
const task = tasks.find((t) => t.id === taskId);
|
|
158
|
-
if (!task) {
|
|
159
|
-
throw new TaskNotFoundError(taskId);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (updates.verified !== undefined) {
|
|
163
|
-
task.verified = updates.verified;
|
|
164
|
-
}
|
|
165
|
-
if (updates.verificationOutput !== undefined) {
|
|
166
|
-
task.verificationOutput = updates.verificationOutput;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
await saveTasks(tasks, id);
|
|
170
|
-
return task;
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Check if a task is blocked by dependencies.
|
|
176
|
-
* A task is blocked if any of its blockedBy tasks are not done.
|
|
177
|
-
*/
|
|
178
|
-
export async function isTaskBlocked(taskId: string, sprintId?: string): Promise<boolean> {
|
|
179
|
-
const tasks = await getTasks(sprintId);
|
|
180
|
-
const task = tasks.find((t) => t.id === taskId);
|
|
181
|
-
if (!task) return false;
|
|
182
|
-
|
|
183
|
-
if (task.blockedBy.length === 0) return false;
|
|
184
|
-
|
|
185
|
-
const doneIds = new Set(tasks.filter((t) => t.status === 'done').map((t) => t.id));
|
|
186
|
-
return !task.blockedBy.every((id) => doneIds.has(id));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function getNextTask(sprintId?: string): Promise<Task | null> {
|
|
190
|
-
const tasks = await getTasks(sprintId);
|
|
191
|
-
|
|
192
|
-
// Priority 1: Resume in_progress task
|
|
193
|
-
const inProgress = tasks.find((t) => t.status === 'in_progress');
|
|
194
|
-
if (inProgress) {
|
|
195
|
-
return inProgress;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Priority 2: First todo task whose dependencies are all done
|
|
199
|
-
const ready = getReadyTasksFromList(tasks);
|
|
200
|
-
return ready[0] ?? null;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Get all tasks from a task list that are ready to execute (unblocked todo tasks).
|
|
205
|
-
* Pure function operating on an in-memory list.
|
|
206
|
-
*/
|
|
207
|
-
export function getReadyTasksFromList(tasks: Tasks): Tasks {
|
|
208
|
-
const doneIds = new Set(tasks.filter((t) => t.status === 'done').map((t) => t.id));
|
|
209
|
-
return tasks
|
|
210
|
-
.filter((t) => t.status === 'todo')
|
|
211
|
-
.filter((t) => t.blockedBy.every((id) => doneIds.has(id)))
|
|
212
|
-
.sort((a, b) => a.order - b.order);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Get all tasks that are ready to execute (unblocked todo tasks).
|
|
217
|
-
* Returns multiple tasks for parallel execution.
|
|
218
|
-
*/
|
|
219
|
-
export async function getReadyTasks(sprintId?: string): Promise<Tasks> {
|
|
220
|
-
const tasks = await getTasks(sprintId);
|
|
221
|
-
return getReadyTasksFromList(tasks);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export async function reorderTask(taskId: string, newOrder: number, sprintId?: string): Promise<Task> {
|
|
225
|
-
const id = await resolveSprintId(sprintId);
|
|
226
|
-
const sprint = await getSprint(id);
|
|
227
|
-
|
|
228
|
-
// Check sprint status - must be draft to reorder tasks
|
|
229
|
-
assertSprintStatus(sprint, ['draft'], 'reorder tasks');
|
|
230
|
-
|
|
231
|
-
const tasksFilePath = getTasksFilePath(id);
|
|
232
|
-
|
|
233
|
-
// Use file lock for atomic read-modify-write
|
|
234
|
-
return withFileLock(tasksFilePath, async () => {
|
|
235
|
-
const tasks = await getTasks(id);
|
|
236
|
-
const task = tasks.find((t) => t.id === taskId);
|
|
237
|
-
if (!task) {
|
|
238
|
-
throw new TaskNotFoundError(taskId);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const oldOrder = task.order;
|
|
242
|
-
task.order = newOrder;
|
|
243
|
-
|
|
244
|
-
// Adjust other task orders
|
|
245
|
-
for (const t of tasks) {
|
|
246
|
-
if (t.id === taskId) continue;
|
|
247
|
-
|
|
248
|
-
if (oldOrder < newOrder) {
|
|
249
|
-
// Moving down: decrement tasks between old and new positions
|
|
250
|
-
if (t.order > oldOrder && t.order <= newOrder) {
|
|
251
|
-
t.order--;
|
|
252
|
-
}
|
|
253
|
-
} else {
|
|
254
|
-
// Moving up: increment tasks between new and old positions
|
|
255
|
-
if (t.order >= newOrder && t.order < oldOrder) {
|
|
256
|
-
t.order++;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
await saveTasks(tasks, id);
|
|
262
|
-
return task;
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export async function listTasks(sprintId?: string): Promise<Tasks> {
|
|
267
|
-
const tasks = await getTasks(sprintId);
|
|
268
|
-
return tasks.sort((a, b) => a.order - b.order);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export async function getRemainingTasks(sprintId?: string): Promise<Tasks> {
|
|
272
|
-
const tasks = await getTasks(sprintId);
|
|
273
|
-
return tasks.filter((t) => t.status !== 'done').sort((a, b) => a.order - b.order);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export async function areAllTasksDone(sprintId?: string): Promise<boolean> {
|
|
277
|
-
const tasks = await getTasks(sprintId);
|
|
278
|
-
return tasks.length > 0 && tasks.every((t) => t.status === 'done');
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Performs topological sort on tasks based on blockedBy dependencies.
|
|
283
|
-
* Returns tasks in dependency order (tasks that block others come first).
|
|
284
|
-
* Throws DependencyCycleError if a cycle is detected.
|
|
285
|
-
*/
|
|
286
|
-
export function topologicalSort(tasks: Tasks): Tasks {
|
|
287
|
-
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
288
|
-
const visited = new Set<string>();
|
|
289
|
-
const visiting = new Set<string>();
|
|
290
|
-
const result: Task[] = [];
|
|
291
|
-
|
|
292
|
-
function visit(taskId: string, path: string[]): void {
|
|
293
|
-
if (visited.has(taskId)) return;
|
|
294
|
-
if (visiting.has(taskId)) {
|
|
295
|
-
// Found a cycle - find where in path it starts
|
|
296
|
-
const cycleStart = path.indexOf(taskId);
|
|
297
|
-
throw new DependencyCycleError([...path.slice(cycleStart), taskId]);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const task = taskMap.get(taskId);
|
|
301
|
-
if (!task) return;
|
|
302
|
-
|
|
303
|
-
visiting.add(taskId);
|
|
304
|
-
|
|
305
|
-
// Visit all tasks this one depends on first
|
|
306
|
-
for (const blockedById of task.blockedBy) {
|
|
307
|
-
visit(blockedById, [...path, taskId]);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
visiting.delete(taskId);
|
|
311
|
-
visited.add(taskId);
|
|
312
|
-
result.push(task);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
for (const task of tasks) {
|
|
316
|
-
visit(task.id, []);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
return result;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Reorders tasks by dependencies and updates their order field.
|
|
324
|
-
* Called at sprint start to ensure task order respects dependencies.
|
|
325
|
-
*/
|
|
326
|
-
export async function reorderByDependencies(sprintId?: string): Promise<void> {
|
|
327
|
-
const id = await resolveSprintId(sprintId);
|
|
328
|
-
const tasksFilePath = getTasksFilePath(id);
|
|
329
|
-
|
|
330
|
-
// Use file lock for atomic read-modify-write
|
|
331
|
-
await withFileLock(tasksFilePath, async () => {
|
|
332
|
-
const tasks = await getTasks(id);
|
|
333
|
-
if (tasks.length === 0) return;
|
|
334
|
-
|
|
335
|
-
const sorted = topologicalSort(tasks);
|
|
336
|
-
|
|
337
|
-
// Update order field based on sorted position
|
|
338
|
-
sorted.forEach((task, index) => {
|
|
339
|
-
task.order = index + 1;
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
await saveTasks(sorted, id);
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Validates import tasks for dependency issues and ticketId references.
|
|
348
|
-
* Tasks can have a local 'id' field that blockedBy references.
|
|
349
|
-
* If ticketIds is provided, validates that ticketId references exist.
|
|
350
|
-
* Returns an array of error messages (empty if valid).
|
|
351
|
-
*/
|
|
352
|
-
export function validateImportTasks(
|
|
353
|
-
importTasks: { id?: string; name: string; blockedBy?: string[]; ticketId?: string }[],
|
|
354
|
-
existingTasks: Tasks,
|
|
355
|
-
ticketIds?: Set<string>
|
|
356
|
-
): string[] {
|
|
357
|
-
const errors: string[] = [];
|
|
358
|
-
|
|
359
|
-
// Validate ticketId references if ticket IDs are provided
|
|
360
|
-
if (ticketIds) {
|
|
361
|
-
for (const task of importTasks) {
|
|
362
|
-
if (task.ticketId && !ticketIds.has(task.ticketId)) {
|
|
363
|
-
errors.push(`Task "${task.name}": ticketId "${task.ticketId}" does not match any ticket in the sprint`);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Build set of all known IDs (local IDs from import + existing task IDs)
|
|
369
|
-
const localIds = new Set(importTasks.map((t) => t.id).filter((id): id is string => !!id));
|
|
370
|
-
const existingIds = new Set(existingTasks.map((t) => t.id));
|
|
371
|
-
const allKnownIds = new Set([...localIds, ...existingIds]);
|
|
372
|
-
|
|
373
|
-
// Build map of local ID to array index for ordering check
|
|
374
|
-
const localIdToIndex = new Map<string, number>();
|
|
375
|
-
importTasks.forEach((task, i) => {
|
|
376
|
-
if (task.id) {
|
|
377
|
-
localIdToIndex.set(task.id, i);
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
// Validate blockedBy references
|
|
382
|
-
importTasks.forEach((task, taskIndex) => {
|
|
383
|
-
for (const depId of task.blockedBy ?? []) {
|
|
384
|
-
if (!allKnownIds.has(depId)) {
|
|
385
|
-
errors.push(`Task "${task.name}": blockedBy "${depId}" does not exist`);
|
|
386
|
-
} else if (localIds.has(depId)) {
|
|
387
|
-
// If referencing a local ID, it must appear earlier in the import array
|
|
388
|
-
const depIndex = localIdToIndex.get(depId);
|
|
389
|
-
if (depIndex !== undefined && depIndex >= taskIndex) {
|
|
390
|
-
errors.push(`Task "${task.name}": blockedBy "${depId}" must reference an earlier task in the import`);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
if (errors.length > 0) {
|
|
397
|
-
return errors;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Generate temporary real IDs for cycle detection
|
|
401
|
-
const tempRealIds = importTasks.map(() => generateUuid8());
|
|
402
|
-
|
|
403
|
-
// Map local IDs to temp real IDs
|
|
404
|
-
const localToTempReal = new Map<string, string>();
|
|
405
|
-
importTasks.forEach((task, i) => {
|
|
406
|
-
if (task.id) {
|
|
407
|
-
localToTempReal.set(task.id, tempRealIds[i] ?? '');
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
// Build combined task list for cycle detection
|
|
412
|
-
const combinedTasks: Tasks = [
|
|
413
|
-
...existingTasks,
|
|
414
|
-
...importTasks.map((t, i) => ({
|
|
415
|
-
id: tempRealIds[i] ?? generateUuid8(),
|
|
416
|
-
name: t.name,
|
|
417
|
-
description: undefined,
|
|
418
|
-
steps: [],
|
|
419
|
-
status: 'todo' as const,
|
|
420
|
-
order: existingTasks.length + i + 1,
|
|
421
|
-
ticketId: undefined,
|
|
422
|
-
blockedBy: (t.blockedBy ?? []).map((depId) => {
|
|
423
|
-
// Convert local ID to temp real ID, or keep existing ID
|
|
424
|
-
return localToTempReal.get(depId) ?? depId;
|
|
425
|
-
}),
|
|
426
|
-
projectPath: '/tmp', // Placeholder for validation only
|
|
427
|
-
verified: false,
|
|
428
|
-
})),
|
|
429
|
-
];
|
|
430
|
-
|
|
431
|
-
// Check for cycles
|
|
432
|
-
try {
|
|
433
|
-
topologicalSort(combinedTasks);
|
|
434
|
-
} catch (err) {
|
|
435
|
-
if (err instanceof DependencyCycleError) {
|
|
436
|
-
errors.push(err.message);
|
|
437
|
-
} else {
|
|
438
|
-
throw err;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return errors;
|
|
443
|
-
}
|