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.
Files changed (130) hide show
  1. package/README.md +58 -24
  2. package/dist/add-HGJCLWED.mjs +14 -0
  3. package/dist/add-MRGCS3US.mjs +14 -0
  4. package/dist/chunk-6PYTKGB5.mjs +316 -0
  5. package/dist/chunk-7TG3EAQ2.mjs +20 -0
  6. package/dist/chunk-EKMZZRWI.mjs +521 -0
  7. package/dist/chunk-JON4GCLR.mjs +59 -0
  8. package/dist/chunk-LOR7QBXX.mjs +3683 -0
  9. package/dist/chunk-MNMQC36F.mjs +556 -0
  10. package/dist/chunk-MRKOFVTM.mjs +537 -0
  11. package/dist/chunk-NTWO2LXB.mjs +52 -0
  12. package/dist/chunk-QBXHAXHI.mjs +562 -0
  13. package/dist/chunk-WGHJI3OI.mjs +214 -0
  14. package/dist/cli.mjs +4245 -0
  15. package/dist/create-MG7E7PLQ.mjs +10 -0
  16. package/dist/handle-UG5M2OON.mjs +22 -0
  17. package/dist/multiline-OHSNFCRG.mjs +40 -0
  18. package/dist/project-NT3L4FTB.mjs +28 -0
  19. package/dist/resolver-WSFWKACM.mjs +153 -0
  20. package/dist/sprint-4VHDLGFN.mjs +37 -0
  21. package/dist/wizard-LRELAN2J.mjs +196 -0
  22. package/package.json +19 -28
  23. package/CHANGELOG.md +0 -94
  24. package/bin/ralphctl +0 -13
  25. package/src/ai/executor.ts +0 -973
  26. package/src/ai/lifecycle.ts +0 -45
  27. package/src/ai/parser.ts +0 -40
  28. package/src/ai/permissions.ts +0 -207
  29. package/src/ai/process-manager.ts +0 -248
  30. package/src/ai/prompts/index.ts +0 -89
  31. package/src/ai/rate-limiter.ts +0 -89
  32. package/src/ai/runner.ts +0 -478
  33. package/src/ai/session.ts +0 -319
  34. package/src/ai/task-context.ts +0 -270
  35. package/src/cli-metadata.ts +0 -7
  36. package/src/cli.ts +0 -65
  37. package/src/commands/completion/index.ts +0 -33
  38. package/src/commands/config/config.ts +0 -58
  39. package/src/commands/config/index.ts +0 -33
  40. package/src/commands/dashboard/dashboard.ts +0 -5
  41. package/src/commands/dashboard/index.ts +0 -6
  42. package/src/commands/doctor/doctor.ts +0 -271
  43. package/src/commands/doctor/index.ts +0 -25
  44. package/src/commands/progress/index.ts +0 -25
  45. package/src/commands/progress/log.ts +0 -64
  46. package/src/commands/progress/show.ts +0 -14
  47. package/src/commands/project/add.ts +0 -336
  48. package/src/commands/project/index.ts +0 -104
  49. package/src/commands/project/list.ts +0 -31
  50. package/src/commands/project/remove.ts +0 -43
  51. package/src/commands/project/repo.ts +0 -118
  52. package/src/commands/project/show.ts +0 -49
  53. package/src/commands/sprint/close.ts +0 -180
  54. package/src/commands/sprint/context.ts +0 -109
  55. package/src/commands/sprint/create.ts +0 -60
  56. package/src/commands/sprint/current.ts +0 -75
  57. package/src/commands/sprint/delete.ts +0 -72
  58. package/src/commands/sprint/health.ts +0 -229
  59. package/src/commands/sprint/ideate.ts +0 -496
  60. package/src/commands/sprint/index.ts +0 -226
  61. package/src/commands/sprint/list.ts +0 -86
  62. package/src/commands/sprint/plan-utils.ts +0 -207
  63. package/src/commands/sprint/plan.ts +0 -549
  64. package/src/commands/sprint/refine.ts +0 -359
  65. package/src/commands/sprint/requirements.ts +0 -58
  66. package/src/commands/sprint/show.ts +0 -140
  67. package/src/commands/sprint/start.ts +0 -119
  68. package/src/commands/sprint/switch.ts +0 -20
  69. package/src/commands/task/add.ts +0 -316
  70. package/src/commands/task/import.ts +0 -150
  71. package/src/commands/task/index.ts +0 -123
  72. package/src/commands/task/list.ts +0 -145
  73. package/src/commands/task/next.ts +0 -45
  74. package/src/commands/task/remove.ts +0 -47
  75. package/src/commands/task/reorder.ts +0 -45
  76. package/src/commands/task/show.ts +0 -111
  77. package/src/commands/task/status.ts +0 -99
  78. package/src/commands/ticket/add.ts +0 -265
  79. package/src/commands/ticket/edit.ts +0 -166
  80. package/src/commands/ticket/index.ts +0 -114
  81. package/src/commands/ticket/list.ts +0 -128
  82. package/src/commands/ticket/refine-utils.ts +0 -89
  83. package/src/commands/ticket/refine.ts +0 -268
  84. package/src/commands/ticket/remove.ts +0 -48
  85. package/src/commands/ticket/show.ts +0 -74
  86. package/src/completion/handle.ts +0 -30
  87. package/src/completion/resolver.ts +0 -241
  88. package/src/interactive/dashboard.ts +0 -268
  89. package/src/interactive/escapable.ts +0 -81
  90. package/src/interactive/file-browser.ts +0 -153
  91. package/src/interactive/index.ts +0 -429
  92. package/src/interactive/menu.ts +0 -403
  93. package/src/interactive/selectors.ts +0 -273
  94. package/src/interactive/wizard.ts +0 -221
  95. package/src/providers/claude.ts +0 -53
  96. package/src/providers/copilot.ts +0 -86
  97. package/src/providers/index.ts +0 -43
  98. package/src/providers/types.ts +0 -85
  99. package/src/schemas/index.ts +0 -130
  100. package/src/store/config.ts +0 -74
  101. package/src/store/progress.ts +0 -230
  102. package/src/store/project.ts +0 -276
  103. package/src/store/sprint.ts +0 -229
  104. package/src/store/task.ts +0 -443
  105. package/src/store/ticket.ts +0 -178
  106. package/src/theme/index.ts +0 -215
  107. package/src/theme/ui.ts +0 -872
  108. package/src/utils/detect-scripts.ts +0 -247
  109. package/src/utils/editor-input.ts +0 -41
  110. package/src/utils/editor.ts +0 -37
  111. package/src/utils/exit-codes.ts +0 -27
  112. package/src/utils/file-lock.ts +0 -135
  113. package/src/utils/git.ts +0 -185
  114. package/src/utils/ids.ts +0 -37
  115. package/src/utils/issue-fetch.ts +0 -244
  116. package/src/utils/json-extract.ts +0 -62
  117. package/src/utils/multiline.ts +0 -61
  118. package/src/utils/path-selector.ts +0 -236
  119. package/src/utils/paths.ts +0 -108
  120. package/src/utils/provider.ts +0 -34
  121. package/src/utils/requirements-export.ts +0 -63
  122. package/src/utils/storage.ts +0 -107
  123. package/tsconfig.json +0 -25
  124. /package/{src/ai → dist}/prompts/ideate-auto.md +0 -0
  125. /package/{src/ai → dist}/prompts/ideate.md +0 -0
  126. /package/{src/ai → dist}/prompts/plan-auto.md +0 -0
  127. /package/{src/ai → dist}/prompts/plan-common.md +0 -0
  128. /package/{src/ai → dist}/prompts/plan-interactive.md +0 -0
  129. /package/{src/ai → dist}/prompts/task-execution.md +0 -0
  130. /package/{src/ai → dist}/prompts/ticket-refine.md +0 -0
@@ -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
- }