@forgeailab/spark 0.1.2 → 0.2.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.
@@ -2,11 +2,10 @@ import { readFile, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { defineCommand } from 'citty';
4
4
  import pc from 'picocolors';
5
- import { readConfig, type AppSkillsConfig } from '../config.ts';
5
+ import type { AppSkillsConfig } from '../config.ts';
6
6
  import { missingBoardTasks } from '../io/board.ts';
7
- import { readRegistry, type Registry } from '../io/registry.ts';
7
+ import type { Registry } from '../io/registry.ts';
8
8
  import { readState } from '../io/state.ts';
9
- import { installedRuntimeHelperSpecifier } from '../runtime-package.ts';
10
9
 
11
10
  type CheckOutput = Pick<Console, 'log' | 'error'>;
12
11
 
@@ -14,7 +13,6 @@ export type DriftReport = {
14
13
  missingFiles: string[];
15
14
  missingEnv: string[];
16
15
  missingTasks: string[];
17
- missingHelpers: string[];
18
16
  };
19
17
 
20
18
  export type CheckOptions = {
@@ -56,13 +54,9 @@ async function readEnvLocal(projectRoot: string): Promise<string> {
56
54
  export async function runCheck(
57
55
  projectRoot = process.cwd(),
58
56
  output: CheckOutput = console,
59
- options: CheckOptions = {},
57
+ _options: CheckOptions = {},
60
58
  ): Promise<DriftReport> {
61
- const [, registry, state] = await Promise.all([
62
- options.config ? Promise.resolve(options.config) : readConfig(projectRoot),
63
- options.registry ? Promise.resolve(options.registry) : readRegistry(projectRoot),
64
- readState(projectRoot),
65
- ]);
59
+ const state = await readState(projectRoot);
66
60
  const recordedFiles = [
67
61
  ...new Set(state.installed_packs.flatMap((pack) => pack.files)),
68
62
  ].sort();
@@ -79,33 +73,17 @@ export async function runCheck(
79
73
  const envLocal = await readEnvLocal(projectRoot);
80
74
  const missingEnv = recordedEnv.filter((key) => !hasEnvVar(envLocal, key));
81
75
  const missingTasks = await missingBoardTasks(projectRoot, recordedTasks);
82
- const missingHelpers: string[] = [];
83
-
84
- for (const pack of state.installed_packs) {
85
- const runtimePackage = registry.packs.get(pack.name)?.manifest.runtime_package;
86
- if (!runtimePackage) {
87
- continue;
88
- }
89
-
90
- if (!(await installedRuntimeHelperSpecifier(projectRoot, runtimePackage))) {
91
- missingHelpers.push(
92
- `${pack.name}: helper package ${runtimePackage.package} missing from package.json`,
93
- );
94
- }
95
- }
96
76
 
97
77
  if (
98
78
  missingFiles.length === 0 &&
99
79
  missingEnv.length === 0 &&
100
- missingTasks.length === 0 &&
101
- missingHelpers.length === 0
80
+ missingTasks.length === 0
102
81
  ) {
103
82
  output.log(pc.green('OK: spark state matches the project filesystem.'));
104
83
  return {
105
84
  missingFiles,
106
85
  missingEnv,
107
86
  missingTasks,
108
- missingHelpers,
109
87
  };
110
88
  }
111
89
 
@@ -132,18 +110,10 @@ export async function runCheck(
132
110
  }
133
111
  }
134
112
 
135
- if (missingHelpers.length > 0) {
136
- output.error('drift: helper packages');
137
- for (const helper of missingHelpers) {
138
- output.error(` ${helper}`);
139
- }
140
- }
141
-
142
113
  return {
143
114
  missingFiles,
144
115
  missingEnv,
145
116
  missingTasks,
146
- missingHelpers,
147
117
  };
148
118
  }
149
119
 
@@ -157,8 +127,7 @@ export const checkCommand = defineCommand({
157
127
  if (
158
128
  report.missingFiles.length > 0 ||
159
129
  report.missingEnv.length > 0 ||
160
- report.missingTasks.length > 0 ||
161
- report.missingHelpers.length > 0
130
+ report.missingTasks.length > 0
162
131
  ) {
163
132
  process.exitCode = 1;
164
133
  }
@@ -3,7 +3,6 @@ import pc from 'picocolors';
3
3
  import { readConfig, type AppSkillsConfig } from '../config.ts';
4
4
  import { readRegistry, type Registry } from '../io/registry.ts';
5
5
  import { installedPackNames, readState } from '../io/state.ts';
6
- import { formatResolvedRuntimeHelper } from '../runtime-package.ts';
7
6
 
8
7
  type InfoOutput = Pick<Console, 'log'>;
9
8
 
@@ -50,13 +49,7 @@ export async function runInfo(
50
49
  output.log(pc.bold(`${manifest.name}@${manifest.version}`));
51
50
  output.log(manifest.description ?? '');
52
51
  output.log(`status: ${installed ? 'installed' : 'available'}`);
53
- output.log(`Install mode: ${manifest.runtime_package ? 'hybrid' : 'copy'}`);
54
- if (manifest.runtime_package) {
55
- const resolved = await formatResolvedRuntimeHelper(projectRoot, manifest.runtime_package);
56
- output.log(
57
- `Runtime helper: ${manifest.runtime_package.package} (range ${manifest.runtime_package.version}, resolved ${resolved})`,
58
- );
59
- }
52
+ output.log('Install mode: copy');
60
53
  output.log(`category: ${manifest.category}`);
61
54
  output.log(`provides: ${formatList(manifest.provides)}`);
62
55
  output.log(`requires: ${formatList(manifest.requires)}`);
@@ -0,0 +1,541 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ export enum BoardTaskStatus {
5
+ Todo = 'todo',
6
+ InProgress = 'in-progress',
7
+ Done = 'done',
8
+ Blocked = 'blocked',
9
+ }
10
+
11
+ export type BoardTask = {
12
+ id: string;
13
+ title: string;
14
+ status: BoardTaskStatus;
15
+ description: string;
16
+ raw: string;
17
+ startLine: number;
18
+ endLine: number;
19
+ };
20
+
21
+ export type BoardEpic = {
22
+ name: string;
23
+ description: string;
24
+ tasks: BoardTask[];
25
+ raw: string;
26
+ startLine: number;
27
+ endLine: number;
28
+ };
29
+
30
+ export type Board = {
31
+ path: string;
32
+ raw: string;
33
+ epics: BoardEpic[];
34
+ toMarkdown(): string;
35
+ };
36
+
37
+ export type SeedTask = {
38
+ id?: string;
39
+ title: string;
40
+ status?: BoardTaskStatus;
41
+ description?: string;
42
+ };
43
+
44
+ type DraftTask = {
45
+ kind: 'checkbox' | 'yaml';
46
+ id: string;
47
+ title: string;
48
+ status: BoardTaskStatus;
49
+ startIndex: number;
50
+ };
51
+
52
+ type DraftEpic = {
53
+ name: string;
54
+ startIndex: number;
55
+ firstTaskIndex?: number;
56
+ tasks: BoardTask[];
57
+ };
58
+
59
+ const statusMarkers: Record<BoardTaskStatus, string> = {
60
+ [BoardTaskStatus.Todo]: ' ',
61
+ [BoardTaskStatus.InProgress]: '~',
62
+ [BoardTaskStatus.Done]: 'x',
63
+ [BoardTaskStatus.Blocked]: '!',
64
+ };
65
+
66
+ function boardFilePath(projectRoot: string): string {
67
+ return join(projectRoot, '.ai', 'board.md');
68
+ }
69
+
70
+ function cleanLine(line: string): string {
71
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
72
+ }
73
+
74
+ function boardLines(raw: string): string[] {
75
+ if (raw.length === 0) {
76
+ return [];
77
+ }
78
+
79
+ return raw.endsWith('\n') ? raw.slice(0, -1).split('\n') : raw.split('\n');
80
+ }
81
+
82
+ function stripYamlValue(value: string): string {
83
+ const trimmed = value.trim();
84
+ if (
85
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
86
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
87
+ ) {
88
+ return trimmed.slice(1, -1);
89
+ }
90
+
91
+ return trimmed;
92
+ }
93
+
94
+ function parseKeyValue(line: string): { key: string; value: string } | undefined {
95
+ const index = line.indexOf(':');
96
+ if (index === -1) {
97
+ return undefined;
98
+ }
99
+
100
+ return {
101
+ key: line.slice(0, index).trim(),
102
+ value: stripYamlValue(line.slice(index + 1)),
103
+ };
104
+ }
105
+
106
+ function parseError(path: string, line: number, message: string): Error {
107
+ return new Error(`Malformed board at ${path}:${line}: ${message}`);
108
+ }
109
+
110
+ function statusFromMarker(path: string, lineNumber: number, marker: string): BoardTaskStatus {
111
+ if (marker === ' ') {
112
+ return BoardTaskStatus.Todo;
113
+ }
114
+ if (marker === '~' || marker === '/') {
115
+ return BoardTaskStatus.InProgress;
116
+ }
117
+ if (marker === 'x' || marker === 'X') {
118
+ return BoardTaskStatus.Done;
119
+ }
120
+ if (marker === '!') {
121
+ return BoardTaskStatus.Blocked;
122
+ }
123
+
124
+ throw parseError(path, lineNumber, `unsupported status marker "${marker}"`);
125
+ }
126
+
127
+ function statusFromValue(value: string): BoardTaskStatus | undefined {
128
+ const normalized = stripYamlValue(value).trim().toLowerCase().replace(/\s+/g, ' ');
129
+
130
+ if (
131
+ normalized === BoardTaskStatus.Todo ||
132
+ normalized === 'to do' ||
133
+ normalized === 'clarifying' ||
134
+ normalized === 'approved for planning'
135
+ ) {
136
+ return BoardTaskStatus.Todo;
137
+ }
138
+
139
+ if (
140
+ normalized === BoardTaskStatus.InProgress ||
141
+ normalized === 'in progress' ||
142
+ normalized === 'approved for execution' ||
143
+ normalized === 'needs review'
144
+ ) {
145
+ return BoardTaskStatus.InProgress;
146
+ }
147
+
148
+ if (normalized === BoardTaskStatus.Done || normalized === 'validated') {
149
+ return BoardTaskStatus.Done;
150
+ }
151
+
152
+ if (normalized === BoardTaskStatus.Blocked || normalized === 'cut from mvp') {
153
+ return BoardTaskStatus.Blocked;
154
+ }
155
+
156
+ return undefined;
157
+ }
158
+
159
+ function assertStatus(status: BoardTaskStatus, path: string): BoardTaskStatus {
160
+ if (Object.values(BoardTaskStatus).includes(status)) {
161
+ return status;
162
+ }
163
+
164
+ throw new Error(`Unsupported board task status "${status}" for ${path}`);
165
+ }
166
+
167
+ function parseCheckboxTask(path: string, lineNumber: number, line: string): DraftTask {
168
+ const match = /^\s*[-*]\s+\[([^\]])\]\s+([^\s:]+)\s*:\s*(.+?)\s*$/u.exec(line);
169
+ if (!match) {
170
+ throw parseError(path, lineNumber, 'expected checkbox task format "- [ ] TASK-ID: Title"');
171
+ }
172
+
173
+ return {
174
+ kind: 'checkbox',
175
+ id: match[2],
176
+ title: match[3],
177
+ status: statusFromMarker(path, lineNumber, match[1]),
178
+ startIndex: lineNumber - 1,
179
+ };
180
+ }
181
+
182
+ function parseYamlTaskStart(path: string, lineNumber: number, line: string): DraftTask {
183
+ const match = /^\s*-\s+id:\s*(.*)$/u.exec(line);
184
+ if (!match) {
185
+ throw parseError(path, lineNumber, 'expected YAML task id');
186
+ }
187
+
188
+ const id = stripYamlValue(match[1]);
189
+ if (id.length === 0) {
190
+ throw parseError(path, lineNumber, 'task id is required');
191
+ }
192
+
193
+ return {
194
+ kind: 'yaml',
195
+ id,
196
+ title: id,
197
+ status: BoardTaskStatus.Todo,
198
+ startIndex: lineNumber - 1,
199
+ };
200
+ }
201
+
202
+ function materializeTask(
203
+ path: string,
204
+ lines: readonly string[],
205
+ draft: DraftTask,
206
+ endIndex: number,
207
+ ): BoardTask {
208
+ const rawLines = lines.slice(draft.startIndex, endIndex);
209
+ let title = draft.title;
210
+ let status = draft.status;
211
+
212
+ if (draft.kind === 'yaml') {
213
+ for (const rawLine of rawLines.slice(1)) {
214
+ const kv = parseKeyValue(cleanLine(rawLine).trim());
215
+ if (!kv) {
216
+ continue;
217
+ }
218
+
219
+ if (kv.key === 'title') {
220
+ title = kv.value || title;
221
+ } else if (kv.key === 'status') {
222
+ const parsed = statusFromValue(kv.value);
223
+ if (!parsed) {
224
+ throw parseError(path, draft.startIndex + 1, `unsupported status "${kv.value}"`);
225
+ }
226
+ status = parsed;
227
+ }
228
+ }
229
+ }
230
+
231
+ return {
232
+ id: draft.id,
233
+ title,
234
+ status,
235
+ description: rawLines.slice(1).join('\n'),
236
+ raw: rawLines.join('\n'),
237
+ startLine: draft.startIndex + 1,
238
+ endLine: endIndex,
239
+ };
240
+ }
241
+
242
+ function parseBoardMarkdown(path: string, raw: string): Board {
243
+ const lines = boardLines(raw);
244
+ const epics: BoardEpic[] = [];
245
+ const seenTaskIds = new Set<string>();
246
+ let currentEpic: DraftEpic | undefined;
247
+ let currentTask: DraftTask | undefined;
248
+
249
+ function finishTask(endIndex: number): void {
250
+ if (!currentTask || !currentEpic) {
251
+ return;
252
+ }
253
+
254
+ const task = materializeTask(path, lines, currentTask, endIndex);
255
+ if (seenTaskIds.has(task.id)) {
256
+ throw parseError(path, task.startLine, `duplicate task id "${task.id}"`);
257
+ }
258
+
259
+ seenTaskIds.add(task.id);
260
+ currentEpic.firstTaskIndex ??= currentTask.startIndex;
261
+ currentEpic.tasks.push(task);
262
+ currentTask = undefined;
263
+ }
264
+
265
+ function finishEpic(endIndex: number): void {
266
+ if (!currentEpic) {
267
+ return;
268
+ }
269
+
270
+ finishTask(endIndex);
271
+ const descriptionEnd = currentEpic.firstTaskIndex ?? endIndex;
272
+ epics.push({
273
+ name: currentEpic.name,
274
+ description: lines.slice(currentEpic.startIndex + 1, descriptionEnd).join('\n'),
275
+ tasks: currentEpic.tasks,
276
+ raw: lines.slice(currentEpic.startIndex, endIndex).join('\n'),
277
+ startLine: currentEpic.startIndex + 1,
278
+ endLine: endIndex,
279
+ });
280
+ currentEpic = undefined;
281
+ }
282
+
283
+ for (let index = 0; index < lines.length; index += 1) {
284
+ const lineNumber = index + 1;
285
+ const line = cleanLine(lines[index]);
286
+ const epicMatch = /^##(?!#)\s+(.+?)\s*$/u.exec(line);
287
+ if (epicMatch) {
288
+ finishEpic(index);
289
+ currentEpic = {
290
+ name: epicMatch[1],
291
+ startIndex: index,
292
+ tasks: [],
293
+ };
294
+ continue;
295
+ }
296
+
297
+ const checkboxLike = /^\s*[-*]\s+\[[^\]]+\]\s+/u.test(line);
298
+ if (checkboxLike) {
299
+ if (!currentEpic) {
300
+ throw parseError(path, lineNumber, 'task appears before any epic heading');
301
+ }
302
+
303
+ finishTask(index);
304
+ currentTask = parseCheckboxTask(path, lineNumber, line);
305
+ continue;
306
+ }
307
+
308
+ const yamlTaskLike = /^\s*-\s+id:/u.test(line);
309
+ if (yamlTaskLike) {
310
+ if (!currentEpic) {
311
+ throw parseError(path, lineNumber, 'task appears before any epic heading');
312
+ }
313
+
314
+ finishTask(index);
315
+ currentTask = parseYamlTaskStart(path, lineNumber, line);
316
+ }
317
+ }
318
+
319
+ finishEpic(lines.length);
320
+
321
+ return {
322
+ path,
323
+ raw,
324
+ epics,
325
+ toMarkdown() {
326
+ return raw;
327
+ },
328
+ };
329
+ }
330
+
331
+ async function readExistingBoard(path: string): Promise<string> {
332
+ try {
333
+ return await readFile(path, 'utf8');
334
+ } catch (error) {
335
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
336
+ return '# Board\n';
337
+ }
338
+ throw error;
339
+ }
340
+ }
341
+
342
+ export async function readBoard(projectRoot: string): Promise<Board> {
343
+ const path = boardFilePath(projectRoot);
344
+
345
+ try {
346
+ const raw = await readFile(path, 'utf8');
347
+ return parseBoardMarkdown(path, raw);
348
+ } catch (error) {
349
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
350
+ throw new Error(`Board file not found at ${path}`, { cause: error });
351
+ }
352
+ throw error;
353
+ }
354
+ }
355
+
356
+ function escapeRegex(value: string): string {
357
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
358
+ }
359
+
360
+ function boardHasTask(board: string, taskId: string): boolean {
361
+ const escaped = escapeRegex(taskId);
362
+ const checkboxPattern = new RegExp(`(^|\\n)\\s*[-*]\\s+\\[[^\\]]+\\]\\s+${escaped}\\s*:`, 'u');
363
+ const yamlPattern = new RegExp(`(^|\\n)\\s*-\\s+id:\\s*["']?${escaped}["']?\\s*(\\n|$)`, 'u');
364
+
365
+ return checkboxPattern.test(board) || yamlPattern.test(board);
366
+ }
367
+
368
+ function generatedTaskId(packName: string, index: number): string {
369
+ const prefix = packName
370
+ .toUpperCase()
371
+ .replace(/[^A-Z0-9]+/g, '-')
372
+ .replace(/^-+|-+$/g, '');
373
+
374
+ return `${prefix || 'PACK'}-${String(index + 1).padStart(3, '0')}`;
375
+ }
376
+
377
+ function normalizeSeedTask(
378
+ path: string,
379
+ packName: string,
380
+ task: SeedTask,
381
+ index: number,
382
+ ): Required<SeedTask> {
383
+ if (task.title.trim().length === 0) {
384
+ throw new Error(`Cannot seed board task with an empty title for ${path}`);
385
+ }
386
+
387
+ return {
388
+ id: task.id?.trim() || generatedTaskId(packName, index),
389
+ title: task.title.trim(),
390
+ status: assertStatus(task.status ?? BoardTaskStatus.Todo, path),
391
+ description: task.description ?? '',
392
+ };
393
+ }
394
+
395
+ function formatSeedTask(task: Required<SeedTask>): string {
396
+ const lines = [`- [${statusMarkers[task.status]}] ${task.id}: ${task.title}`];
397
+ const description = task.description.replace(/\r\n/g, '\n').replace(/\n$/u, '');
398
+
399
+ if (description.trim().length > 0) {
400
+ lines.push(...description.split('\n').map((line) => ` ${line}`));
401
+ }
402
+
403
+ return lines.join('\n');
404
+ }
405
+
406
+ function lineStartOffsets(raw: string): number[] {
407
+ const offsets = [0];
408
+ for (let index = 0; index < raw.length; index += 1) {
409
+ if (raw[index] === '\n') {
410
+ offsets.push(index + 1);
411
+ }
412
+ }
413
+
414
+ return offsets;
415
+ }
416
+
417
+ function findSectionInsertIndex(board: string, packName: string): number | undefined {
418
+ const heading = `## ${packName}`;
419
+ const lines = board.split('\n');
420
+ const offsets = lineStartOffsets(board);
421
+ let headingIndex = -1;
422
+
423
+ for (let index = 0; index < lines.length; index += 1) {
424
+ if (cleanLine(lines[index]).trim() === heading) {
425
+ headingIndex = index;
426
+ break;
427
+ }
428
+ }
429
+
430
+ if (headingIndex === -1) {
431
+ return undefined;
432
+ }
433
+
434
+ for (let index = headingIndex + 1; index < lines.length; index += 1) {
435
+ if (/^##(?!#)\s+/u.test(cleanLine(lines[index]))) {
436
+ return offsets[index] ?? board.length;
437
+ }
438
+ }
439
+
440
+ return board.length;
441
+ }
442
+
443
+ function appendTasks(board: string, packName: string, tasks: readonly Required<SeedTask>[]): string {
444
+ const taskBlock = tasks.map(formatSeedTask).join('\n\n');
445
+ const insertIndex = findSectionInsertIndex(board, packName);
446
+
447
+ if (insertIndex === undefined) {
448
+ const separator = board.endsWith('\n') ? '\n' : '\n\n';
449
+ return `${board}${separator}## ${packName}\n\n${taskBlock}\n`;
450
+ }
451
+
452
+ const before = board.slice(0, insertIndex);
453
+ const after = board.slice(insertIndex);
454
+ const separator = before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
455
+ const afterSeparator = after.length > 0 ? '\n' : '';
456
+
457
+ return `${before}${separator}${taskBlock}\n${afterSeparator}${after}`;
458
+ }
459
+
460
+ export async function seedTasks(
461
+ projectRoot: string,
462
+ packName: string,
463
+ tasks: Array<SeedTask>,
464
+ ): Promise<void> {
465
+ if (tasks.length === 0) {
466
+ return;
467
+ }
468
+
469
+ const path = boardFilePath(projectRoot);
470
+ const board = await readExistingBoard(path);
471
+ const normalizedTasks = tasks
472
+ .map((task, index) => normalizeSeedTask(path, packName, task, index))
473
+ .filter((task) => !boardHasTask(board, task.id));
474
+
475
+ if (normalizedTasks.length === 0) {
476
+ return;
477
+ }
478
+
479
+ await mkdir(dirname(path), { recursive: true });
480
+ await writeFile(path, appendTasks(board, packName, normalizedTasks));
481
+ }
482
+
483
+ export async function updateStatus(
484
+ projectRoot: string,
485
+ taskId: string,
486
+ status: BoardTaskStatus,
487
+ ): Promise<void> {
488
+ const path = boardFilePath(projectRoot);
489
+ const nextStatus = assertStatus(status, path);
490
+ const raw = await readFile(path, 'utf8');
491
+ const lines = raw.split('\n');
492
+ const marker = statusMarkers[nextStatus];
493
+ const checkboxPattern = new RegExp(
494
+ `^(\\s*[-*]\\s+\\[)([^\\]])(\\]\\s+${escapeRegex(taskId)}\\s*:\\s*.+)$`,
495
+ 'u',
496
+ );
497
+
498
+ for (let index = 0; index < lines.length; index += 1) {
499
+ const originalLine = lines[index];
500
+ const hasCarriage = originalLine.endsWith('\r');
501
+ const line = cleanLine(originalLine);
502
+ const checkboxMatch = checkboxPattern.exec(line);
503
+
504
+ if (checkboxMatch) {
505
+ lines[index] = `${checkboxMatch[1]}${marker}${checkboxMatch[3]}${hasCarriage ? '\r' : ''}`;
506
+ const nextRaw = lines.join('\n');
507
+ if (nextRaw !== raw) {
508
+ await writeFile(path, nextRaw);
509
+ }
510
+ return;
511
+ }
512
+
513
+ const yamlMatch = /^\s*-\s+id:\s*(.*)$/u.exec(line);
514
+ if (yamlMatch && stripYamlValue(yamlMatch[1]) === taskId) {
515
+ for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
516
+ const candidate = cleanLine(lines[nextIndex]);
517
+ if (/^##(?!#)\s+/u.test(candidate) || /^\s*-\s+id:/u.test(candidate)) {
518
+ break;
519
+ }
520
+
521
+ const statusMatch = /^(\s*status:\s*)(.*?)(\s*)$/u.exec(candidate);
522
+ if (!statusMatch) {
523
+ continue;
524
+ }
525
+
526
+ const candidateHasCarriage = lines[nextIndex].endsWith('\r');
527
+ lines[nextIndex] =
528
+ `${statusMatch[1]}${nextStatus}${statusMatch[3]}${candidateHasCarriage ? '\r' : ''}`;
529
+ const nextRaw = lines.join('\n');
530
+ if (nextRaw !== raw) {
531
+ await writeFile(path, nextRaw);
532
+ }
533
+ return;
534
+ }
535
+
536
+ throw new Error(`Task "${taskId}" has no status marker in ${path}`);
537
+ }
538
+ }
539
+
540
+ throw new Error(`Task "${taskId}" not found in ${path}`);
541
+ }