specsmd 0.0.0-dev.54 → 0.0.0-dev.55

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.
@@ -1,806 +0,0 @@
1
- /**
2
- * Complete a run
3
- *
4
- * Finalizes run record and updates state.yaml
5
- *
6
- * This script is designed to be used by AI agents and provides:
7
- * - Clear, actionable error messages
8
- * - Input validation with helpful guidance
9
- * - Graceful handling of data quality issues
10
- * - Warnings for non-fatal problems
11
- */
12
-
13
- import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
14
- import { join } from 'path';
15
- import * as yaml from 'yaml';
16
-
17
- // ============================================================================
18
- // Types
19
- // ============================================================================
20
-
21
- interface FileChange {
22
- path: string;
23
- purpose?: string;
24
- changes?: string;
25
- }
26
-
27
- interface Decision {
28
- decision: string;
29
- choice: string;
30
- rationale: string;
31
- }
32
-
33
- interface RunCompletion {
34
- runId: string;
35
- filesCreated: Array<{ path: string; purpose: string }>;
36
- filesModified: Array<{ path: string; changes: string }>;
37
- decisions: Array<Decision>;
38
- testsAdded: number;
39
- coverage: number;
40
- }
41
-
42
- interface ActiveRun {
43
- id: string;
44
- work_item: string;
45
- intent: string;
46
- mode?: string;
47
- started?: string;
48
- }
49
-
50
- interface WorkItem {
51
- id: string;
52
- status: string;
53
- run_id?: string;
54
- }
55
-
56
- interface Intent {
57
- id: string;
58
- work_items: WorkItem[];
59
- }
60
-
61
- interface State {
62
- intents: Intent[];
63
- active_run: ActiveRun | null;
64
- }
65
-
66
- interface CompletionResult {
67
- success: boolean;
68
- runId: string;
69
- completedAt: string;
70
- workItemId: string;
71
- intentId: string;
72
- warnings: string[];
73
- }
74
-
75
- // ============================================================================
76
- // Custom Error Class
77
- // ============================================================================
78
-
79
- class FIREError extends Error {
80
- constructor(
81
- message: string,
82
- public readonly context?: Record<string, unknown>
83
- ) {
84
- super(message);
85
- this.name = 'FIREError';
86
- }
87
- }
88
-
89
- /**
90
- * Creates a standardized FIRE error message
91
- */
92
- function fireError(what: string, why: string, howToFix: string, context?: Record<string, unknown>): FIREError {
93
- const message = `FIRE Error: ${what}. ${why}. ${howToFix}`;
94
- return new FIREError(message, context);
95
- }
96
-
97
- // ============================================================================
98
- // Validation Functions
99
- // ============================================================================
100
-
101
- /**
102
- * Validates the root path exists and is accessible
103
- */
104
- function validateRootPath(rootPath: unknown): asserts rootPath is string {
105
- if (rootPath === null || rootPath === undefined) {
106
- throw fireError(
107
- 'Missing root path',
108
- 'The rootPath parameter was not provided',
109
- 'Ensure the rootPath is passed to completeRun(rootPath, params)'
110
- );
111
- }
112
-
113
- if (typeof rootPath !== 'string') {
114
- throw fireError(
115
- 'Invalid root path type',
116
- `Expected string but received ${typeof rootPath}`,
117
- 'Pass a valid string path to completeRun()',
118
- { receivedType: typeof rootPath }
119
- );
120
- }
121
-
122
- if (rootPath.trim() === '') {
123
- throw fireError(
124
- 'Empty root path',
125
- 'The rootPath parameter is an empty string',
126
- 'Provide a valid project root path'
127
- );
128
- }
129
-
130
- if (!existsSync(rootPath)) {
131
- throw fireError(
132
- `Project root not found at '${rootPath}'`,
133
- 'The specified directory does not exist',
134
- 'Verify the path is correct and the project directory exists',
135
- { path: rootPath }
136
- );
137
- }
138
-
139
- const stats = statSync(rootPath);
140
- if (!stats.isDirectory()) {
141
- throw fireError(
142
- `Root path is not a directory: '${rootPath}'`,
143
- 'The specified path exists but is a file, not a directory',
144
- 'Provide the path to the project root directory',
145
- { path: rootPath }
146
- );
147
- }
148
- }
149
-
150
- /**
151
- * Validates the FIRE project structure exists
152
- */
153
- function validateProjectStructure(rootPath: string): { statePath: string; runsPath: string } {
154
- const fireDir = join(rootPath, '.specs-fire');
155
- const statePath = join(fireDir, 'state.yaml');
156
- const runsPath = join(fireDir, 'runs');
157
-
158
- if (!existsSync(fireDir)) {
159
- throw fireError(
160
- `FIRE project not initialized at '${rootPath}'`,
161
- `The .specs-fire directory was not found`,
162
- 'Initialize the project first using the fire-init skill or ensure you are in the correct project directory',
163
- { expectedPath: fireDir }
164
- );
165
- }
166
-
167
- if (!existsSync(statePath)) {
168
- throw fireError(
169
- `State file not found at '${statePath}'`,
170
- 'The state.yaml file is missing from the .specs-fire directory',
171
- 'The project may be partially initialized. Try re-initializing with fire-init',
172
- { statePath }
173
- );
174
- }
175
-
176
- if (!existsSync(runsPath)) {
177
- throw fireError(
178
- `Runs directory not found at '${runsPath}'`,
179
- 'The runs directory is missing from the .specs-fire directory',
180
- 'The project may be partially initialized. Try re-initializing with fire-init',
181
- { runsPath }
182
- );
183
- }
184
-
185
- return { statePath, runsPath };
186
- }
187
-
188
- /**
189
- * Validates the RunCompletion parameters
190
- */
191
- function validateRunCompletionParams(params: unknown): { validated: RunCompletion; warnings: string[] } {
192
- const warnings: string[] = [];
193
-
194
- if (params === null || params === undefined) {
195
- throw fireError(
196
- 'Missing completion parameters',
197
- 'The params object was not provided',
198
- 'Pass a RunCompletion object with runId, filesCreated, filesModified, decisions, testsAdded, and coverage'
199
- );
200
- }
201
-
202
- if (typeof params !== 'object') {
203
- throw fireError(
204
- 'Invalid params type',
205
- `Expected object but received ${typeof params}`,
206
- 'Pass a valid RunCompletion object',
207
- { receivedType: typeof params }
208
- );
209
- }
210
-
211
- const p = params as Record<string, unknown>;
212
-
213
- // Validate runId (required)
214
- if (!p.runId || typeof p.runId !== 'string' || p.runId.trim() === '') {
215
- throw fireError(
216
- 'Missing or invalid runId',
217
- 'The runId parameter is required and must be a non-empty string',
218
- 'Provide the run ID to complete (e.g., "run-001")',
219
- { receivedRunId: p.runId }
220
- );
221
- }
222
-
223
- // Validate filesCreated (optional, default to empty array)
224
- let filesCreated: Array<{ path: string; purpose: string }> = [];
225
- if (p.filesCreated === null || p.filesCreated === undefined) {
226
- warnings.push('filesCreated was not provided, defaulting to empty array');
227
- } else if (!Array.isArray(p.filesCreated)) {
228
- warnings.push(`filesCreated should be an array but received ${typeof p.filesCreated}, defaulting to empty array`);
229
- } else {
230
- filesCreated = (p.filesCreated as unknown[])
231
- .map((item, index) => {
232
- if (!item || typeof item !== 'object') {
233
- warnings.push(`filesCreated[${index}] is not an object, skipping`);
234
- return null;
235
- }
236
- const f = item as Record<string, unknown>;
237
- if (!f.path || typeof f.path !== 'string') {
238
- warnings.push(`filesCreated[${index}] missing 'path' property, skipping`);
239
- return null;
240
- }
241
- return {
242
- path: f.path,
243
- purpose: typeof f.purpose === 'string' ? f.purpose : '(no purpose specified)',
244
- };
245
- })
246
- .filter((item): item is { path: string; purpose: string } => item !== null);
247
- }
248
-
249
- // Validate filesModified (optional, default to empty array)
250
- let filesModified: Array<{ path: string; changes: string }> = [];
251
- if (p.filesModified === null || p.filesModified === undefined) {
252
- warnings.push('filesModified was not provided, defaulting to empty array');
253
- } else if (!Array.isArray(p.filesModified)) {
254
- warnings.push(`filesModified should be an array but received ${typeof p.filesModified}, defaulting to empty array`);
255
- } else {
256
- filesModified = (p.filesModified as unknown[])
257
- .map((item, index) => {
258
- if (!item || typeof item !== 'object') {
259
- warnings.push(`filesModified[${index}] is not an object, skipping`);
260
- return null;
261
- }
262
- const f = item as Record<string, unknown>;
263
- if (!f.path || typeof f.path !== 'string') {
264
- warnings.push(`filesModified[${index}] missing 'path' property, skipping`);
265
- return null;
266
- }
267
- return {
268
- path: f.path,
269
- changes: typeof f.changes === 'string' ? f.changes : '(no changes specified)',
270
- };
271
- })
272
- .filter((item): item is { path: string; changes: string } => item !== null);
273
- }
274
-
275
- // Validate decisions (optional, default to empty array)
276
- let decisions: Decision[] = [];
277
- if (p.decisions === null || p.decisions === undefined) {
278
- warnings.push('decisions was not provided, defaulting to empty array');
279
- } else if (!Array.isArray(p.decisions)) {
280
- warnings.push(`decisions should be an array but received ${typeof p.decisions}, defaulting to empty array`);
281
- } else {
282
- decisions = (p.decisions as unknown[])
283
- .map((item, index) => {
284
- if (!item || typeof item !== 'object') {
285
- warnings.push(`decisions[${index}] is not an object, skipping`);
286
- return null;
287
- }
288
- const d = item as Record<string, unknown>;
289
- if (!d.decision || typeof d.decision !== 'string') {
290
- warnings.push(`decisions[${index}] missing 'decision' property, skipping`);
291
- return null;
292
- }
293
- return {
294
- decision: d.decision,
295
- choice: typeof d.choice === 'string' ? d.choice : '(no choice specified)',
296
- rationale: typeof d.rationale === 'string' ? d.rationale : '(no rationale specified)',
297
- };
298
- })
299
- .filter((item): item is Decision => item !== null);
300
- }
301
-
302
- // Validate testsAdded (optional, default to 0)
303
- let testsAdded = 0;
304
- if (p.testsAdded === null || p.testsAdded === undefined) {
305
- warnings.push('testsAdded was not provided, defaulting to 0');
306
- } else if (typeof p.testsAdded !== 'number' || isNaN(p.testsAdded)) {
307
- warnings.push(`testsAdded should be a number but received ${typeof p.testsAdded}, defaulting to 0`);
308
- } else if (p.testsAdded < 0) {
309
- warnings.push(`testsAdded was negative (${p.testsAdded}), using 0 instead`);
310
- } else {
311
- testsAdded = Math.floor(p.testsAdded);
312
- }
313
-
314
- // Validate coverage (optional, default to 0)
315
- let coverage = 0;
316
- if (p.coverage === null || p.coverage === undefined) {
317
- warnings.push('coverage was not provided, defaulting to 0');
318
- } else if (typeof p.coverage !== 'number' || isNaN(p.coverage)) {
319
- warnings.push(`coverage should be a number but received ${typeof p.coverage}, defaulting to 0`);
320
- } else if (p.coverage < 0) {
321
- warnings.push(`coverage was negative (${p.coverage}), using 0 instead`);
322
- } else if (p.coverage > 100) {
323
- warnings.push(`coverage was greater than 100 (${p.coverage}), capping at 100`);
324
- coverage = 100;
325
- } else {
326
- coverage = p.coverage;
327
- }
328
-
329
- return {
330
- validated: {
331
- runId: p.runId as string,
332
- filesCreated,
333
- filesModified,
334
- decisions,
335
- testsAdded,
336
- coverage,
337
- },
338
- warnings,
339
- };
340
- }
341
-
342
- // ============================================================================
343
- // Safe File Operations
344
- // ============================================================================
345
-
346
- /**
347
- * Safely reads a file with clear error messages
348
- */
349
- function safeReadFile(filePath: string, purpose: string): string {
350
- try {
351
- return readFileSync(filePath, 'utf8');
352
- } catch (error) {
353
- const err = error as NodeJS.ErrnoException;
354
- if (err.code === 'ENOENT') {
355
- throw fireError(
356
- `Cannot read ${purpose} - file not found`,
357
- `The file at '${filePath}' does not exist`,
358
- 'Ensure the file exists and the path is correct. If this is a run log, the run may not have been properly initialized',
359
- { filePath, errorCode: err.code }
360
- );
361
- }
362
- if (err.code === 'EACCES') {
363
- throw fireError(
364
- `Cannot read ${purpose} - permission denied`,
365
- `Insufficient permissions to read '${filePath}'`,
366
- 'Check file permissions and ensure the process has read access',
367
- { filePath, errorCode: err.code }
368
- );
369
- }
370
- throw fireError(
371
- `Cannot read ${purpose}`,
372
- `Unexpected error reading '${filePath}': ${err.message}`,
373
- 'Check the file path and permissions',
374
- { filePath, errorCode: err.code, errorMessage: err.message }
375
- );
376
- }
377
- }
378
-
379
- /**
380
- * Safely writes a file with clear error messages
381
- */
382
- function safeWriteFile(filePath: string, content: string, purpose: string): void {
383
- try {
384
- writeFileSync(filePath, content);
385
- } catch (error) {
386
- const err = error as NodeJS.ErrnoException;
387
- if (err.code === 'EACCES') {
388
- throw fireError(
389
- `Cannot write ${purpose} - permission denied`,
390
- `Insufficient permissions to write to '${filePath}'`,
391
- 'Check file permissions and ensure the process has write access',
392
- { filePath, errorCode: err.code }
393
- );
394
- }
395
- if (err.code === 'ENOSPC') {
396
- throw fireError(
397
- `Cannot write ${purpose} - disk full`,
398
- `No space left on device to write '${filePath}'`,
399
- 'Free up disk space and try again',
400
- { filePath, errorCode: err.code }
401
- );
402
- }
403
- throw fireError(
404
- `Cannot write ${purpose}`,
405
- `Unexpected error writing '${filePath}': ${err.message}`,
406
- 'Check the file path and permissions',
407
- { filePath, errorCode: err.code, errorMessage: err.message }
408
- );
409
- }
410
- }
411
-
412
- /**
413
- * Safely parses YAML content with clear error messages
414
- */
415
- function safeParseYaml<T>(content: string, filePath: string, purpose: string): T {
416
- try {
417
- const parsed = yaml.parse(content);
418
- if (parsed === null || parsed === undefined) {
419
- throw fireError(
420
- `${purpose} is empty or invalid`,
421
- `The file at '${filePath}' contains no valid YAML data`,
422
- 'Check the file contents and ensure it contains valid YAML',
423
- { filePath }
424
- );
425
- }
426
- return parsed as T;
427
- } catch (error) {
428
- if (error instanceof FIREError) {
429
- throw error;
430
- }
431
- const err = error as Error;
432
- throw fireError(
433
- `Cannot parse ${purpose} as YAML`,
434
- `Invalid YAML syntax in '${filePath}': ${err.message}`,
435
- 'Check the file for YAML syntax errors (incorrect indentation, missing colons, etc.)',
436
- { filePath, parseError: err.message }
437
- );
438
- }
439
- }
440
-
441
- // ============================================================================
442
- // State Validation
443
- // ============================================================================
444
-
445
- /**
446
- * Validates state structure and active run
447
- */
448
- function validateState(state: unknown, statePath: string, runId: string): { state: State; warnings: string[] } {
449
- const warnings: string[] = [];
450
-
451
- if (typeof state !== 'object' || state === null) {
452
- throw fireError(
453
- 'Invalid state file structure',
454
- 'The state.yaml file does not contain a valid object',
455
- 'Check the state.yaml file format and ensure it follows the FIRE schema',
456
- { statePath }
457
- );
458
- }
459
-
460
- const s = state as Record<string, unknown>;
461
-
462
- // Validate active_run exists
463
- if (s.active_run === null || s.active_run === undefined) {
464
- throw fireError(
465
- 'Cannot complete run - no active run found in state.yaml',
466
- 'The run may have already been completed, was never started, or was cancelled',
467
- 'Start a new run using run-init before attempting to complete. Check state.yaml to see the current project state',
468
- { statePath, runId }
469
- );
470
- }
471
-
472
- if (typeof s.active_run !== 'object') {
473
- throw fireError(
474
- 'Invalid active_run in state.yaml',
475
- `active_run should be an object but is ${typeof s.active_run}`,
476
- 'The state.yaml file may be corrupted. Check the active_run section',
477
- { statePath, activeRunType: typeof s.active_run }
478
- );
479
- }
480
-
481
- const activeRun = s.active_run as Record<string, unknown>;
482
-
483
- // Validate active_run has required fields
484
- if (!activeRun.id || typeof activeRun.id !== 'string') {
485
- throw fireError(
486
- 'Active run missing ID',
487
- 'The active_run in state.yaml does not have a valid id field',
488
- 'The state.yaml file may be corrupted. Check the active_run.id field',
489
- { statePath }
490
- );
491
- }
492
-
493
- // Validate runId matches active_run.id
494
- if (activeRun.id !== runId) {
495
- throw fireError(
496
- `Run ID mismatch - attempting to complete '${runId}' but active run is '${activeRun.id}'`,
497
- 'The run you are trying to complete does not match the currently active run',
498
- `Either complete the active run '${activeRun.id}' first, or verify you are using the correct run ID`,
499
- { attemptedRunId: runId, activeRunId: activeRun.id }
500
- );
501
- }
502
-
503
- if (!activeRun.work_item || typeof activeRun.work_item !== 'string') {
504
- throw fireError(
505
- 'Active run missing work_item reference',
506
- 'The active_run in state.yaml does not have a valid work_item field',
507
- 'The state.yaml file may be corrupted. Check the active_run.work_item field',
508
- { statePath }
509
- );
510
- }
511
-
512
- if (!activeRun.intent || typeof activeRun.intent !== 'string') {
513
- throw fireError(
514
- 'Active run missing intent reference',
515
- 'The active_run in state.yaml does not have a valid intent field',
516
- 'The state.yaml file may be corrupted. Check the active_run.intent field',
517
- { statePath }
518
- );
519
- }
520
-
521
- // Validate intents array exists
522
- if (!Array.isArray(s.intents)) {
523
- if (s.intents === null || s.intents === undefined) {
524
- warnings.push('intents array is missing from state.yaml, will not update work item status');
525
- } else {
526
- warnings.push(`intents should be an array but is ${typeof s.intents}, will not update work item status`);
527
- }
528
- }
529
-
530
- return {
531
- state: {
532
- intents: Array.isArray(s.intents) ? (s.intents as Intent[]) : [],
533
- active_run: {
534
- id: activeRun.id as string,
535
- work_item: activeRun.work_item as string,
536
- intent: activeRun.intent as string,
537
- mode: typeof activeRun.mode === 'string' ? activeRun.mode : undefined,
538
- started: typeof activeRun.started === 'string' ? activeRun.started : undefined,
539
- },
540
- },
541
- warnings,
542
- };
543
- }
544
-
545
- /**
546
- * Validates and updates work item status
547
- */
548
- function updateWorkItemStatus(
549
- state: State,
550
- intentId: string,
551
- workItemId: string,
552
- runId: string
553
- ): { updated: boolean; warnings: string[] } {
554
- const warnings: string[] = [];
555
-
556
- if (state.intents.length === 0) {
557
- warnings.push(`No intents found in state - work item '${workItemId}' status was not updated`);
558
- return { updated: false, warnings };
559
- }
560
-
561
- const intent = state.intents.find((i) => i.id === intentId);
562
- if (!intent) {
563
- warnings.push(
564
- `Intent '${intentId}' not found in state.intents - work item '${workItemId}' status was not updated. ` +
565
- 'The intent may have been deleted or renamed'
566
- );
567
- return { updated: false, warnings };
568
- }
569
-
570
- if (!Array.isArray(intent.work_items)) {
571
- warnings.push(
572
- `Intent '${intentId}' has no work_items array - work item '${workItemId}' status was not updated`
573
- );
574
- return { updated: false, warnings };
575
- }
576
-
577
- const workItem = intent.work_items.find((w) => w.id === workItemId);
578
- if (!workItem) {
579
- warnings.push(
580
- `Work item '${workItemId}' not found in intent '${intentId}' - status was not updated. ` +
581
- 'The work item may have been deleted or renamed'
582
- );
583
- return { updated: false, warnings };
584
- }
585
-
586
- // Check if already completed
587
- if (workItem.status === 'completed') {
588
- warnings.push(
589
- `Work item '${workItemId}' was already marked as completed. Updating run_id to '${runId}'`
590
- );
591
- }
592
-
593
- workItem.status = 'completed';
594
- workItem.run_id = runId;
595
- return { updated: true, warnings };
596
- }
597
-
598
- // ============================================================================
599
- // Run Log Update
600
- // ============================================================================
601
-
602
- /**
603
- * Updates the run log file with completion data
604
- */
605
- function updateRunLog(
606
- runLogPath: string,
607
- runLogContent: string,
608
- params: RunCompletion,
609
- completedTime: string
610
- ): { updated: string; warnings: string[] } {
611
- const warnings: string[] = [];
612
- let updatedContent = runLogContent;
613
-
614
- // Check if already completed
615
- if (runLogContent.includes('status: completed')) {
616
- warnings.push('Run log already shows status: completed - this run may have been completed before');
617
- }
618
-
619
- // Update status
620
- if (runLogContent.includes('status: in_progress')) {
621
- updatedContent = updatedContent.replace(/status: in_progress/, 'status: completed');
622
- } else {
623
- warnings.push('Could not find "status: in_progress" in run log - status may not have been updated');
624
- }
625
-
626
- // Update completed timestamp
627
- if (runLogContent.includes('completed: null')) {
628
- updatedContent = updatedContent.replace(/completed: null/, `completed: ${completedTime}`);
629
- } else {
630
- warnings.push('Could not find "completed: null" in run log - completion timestamp may not have been set');
631
- }
632
-
633
- // Build sections
634
- const filesCreatedSection =
635
- params.filesCreated.length > 0
636
- ? params.filesCreated.map((f) => `- \`${f.path}\`: ${f.purpose}`).join('\n')
637
- : '(none)';
638
-
639
- const filesModifiedSection =
640
- params.filesModified.length > 0
641
- ? params.filesModified.map((f) => `- \`${f.path}\`: ${f.changes}`).join('\n')
642
- : '(none)';
643
-
644
- const decisionsSection =
645
- params.decisions.length > 0
646
- ? params.decisions.map((d) => `- **${d.decision}**: ${d.choice} (${d.rationale})`).join('\n')
647
- : '(none)';
648
-
649
- // Update sections
650
- if (runLogContent.includes('## Files Created\n(none yet)')) {
651
- updatedContent = updatedContent.replace('## Files Created\n(none yet)', `## Files Created\n${filesCreatedSection}`);
652
- } else {
653
- warnings.push('Could not find "## Files Created\\n(none yet)" pattern - files created section may not have been updated');
654
- }
655
-
656
- if (runLogContent.includes('## Files Modified\n(none yet)')) {
657
- updatedContent = updatedContent.replace(
658
- '## Files Modified\n(none yet)',
659
- `## Files Modified\n${filesModifiedSection}`
660
- );
661
- } else {
662
- warnings.push(
663
- 'Could not find "## Files Modified\\n(none yet)" pattern - files modified section may not have been updated'
664
- );
665
- }
666
-
667
- if (runLogContent.includes('## Decisions\n(none yet)')) {
668
- updatedContent = updatedContent.replace('## Decisions\n(none yet)', `## Decisions\n${decisionsSection}`);
669
- } else {
670
- warnings.push('Could not find "## Decisions\\n(none yet)" pattern - decisions section may not have been updated');
671
- }
672
-
673
- // Check if summary already exists to avoid duplicates
674
- if (runLogContent.includes('## Summary')) {
675
- warnings.push('Run log already contains a Summary section - not adding duplicate');
676
- } else {
677
- // Add summary
678
- updatedContent += `
679
-
680
- ## Summary
681
-
682
- - Files created: ${params.filesCreated.length}
683
- - Files modified: ${params.filesModified.length}
684
- - Tests added: ${params.testsAdded}
685
- - Coverage: ${params.coverage}%
686
- - Completed: ${completedTime}
687
- `;
688
- }
689
-
690
- return { updated: updatedContent, warnings };
691
- }
692
-
693
- // ============================================================================
694
- // Main Function
695
- // ============================================================================
696
-
697
- /**
698
- * Completes a FIRE run and updates state
699
- *
700
- * @param rootPath - The project root path
701
- * @param params - Run completion parameters
702
- * @returns CompletionResult with success status, run details, and any warnings
703
- * @throws FIREError with clear, actionable error messages for critical failures
704
- */
705
- export function completeRun(rootPath: string, params: RunCompletion): CompletionResult {
706
- const allWarnings: string[] = [];
707
-
708
- // ============================================
709
- // Phase 1: Input Validation (fail fast)
710
- // ============================================
711
-
712
- // Validate root path
713
- validateRootPath(rootPath);
714
-
715
- // Validate project structure
716
- const { statePath, runsPath } = validateProjectStructure(rootPath);
717
-
718
- // Validate and normalize params
719
- const { validated: validatedParams, warnings: paramWarnings } = validateRunCompletionParams(params);
720
- allWarnings.push(...paramWarnings);
721
-
722
- // Validate run directory exists
723
- const runPath = join(runsPath, validatedParams.runId);
724
- if (!existsSync(runPath)) {
725
- throw fireError(
726
- `Run directory not found: '${validatedParams.runId}'`,
727
- `The directory at '${runPath}' does not exist`,
728
- 'Ensure the run was properly initialized with run-init before attempting to complete',
729
- { runPath, runId: validatedParams.runId }
730
- );
731
- }
732
-
733
- const runLogPath = join(runPath, 'run.md');
734
- if (!existsSync(runLogPath)) {
735
- throw fireError(
736
- `Run log not found for '${validatedParams.runId}'`,
737
- `The run.md file at '${runLogPath}' does not exist`,
738
- 'The run may have been partially initialized. Check the run directory and try re-initializing',
739
- { runLogPath, runId: validatedParams.runId }
740
- );
741
- }
742
-
743
- // ============================================
744
- // Phase 2: Read and Validate State
745
- // ============================================
746
-
747
- const stateContent = safeReadFile(statePath, 'state file');
748
- const parsedState = safeParseYaml<unknown>(stateContent, statePath, 'state file');
749
- const { state, warnings: stateWarnings } = validateState(parsedState, statePath, validatedParams.runId);
750
- allWarnings.push(...stateWarnings);
751
-
752
- // At this point we know active_run is valid
753
- const { work_item: workItemId, intent: intentId } = state.active_run!;
754
-
755
- // ============================================
756
- // Phase 3: Update State
757
- // ============================================
758
-
759
- // Update work item status
760
- const { warnings: workItemWarnings } = updateWorkItemStatus(state, intentId, workItemId, validatedParams.runId);
761
- allWarnings.push(...workItemWarnings);
762
-
763
- // Clear active run
764
- (state as { active_run: ActiveRun | null }).active_run = null;
765
-
766
- // Save state - do this before run log to minimize inconsistency window
767
- // Preserve other state fields that we didn't modify
768
- const originalState = parsedState as Record<string, unknown>;
769
- const updatedStateObj = {
770
- ...originalState,
771
- intents: state.intents,
772
- active_run: null,
773
- };
774
-
775
- safeWriteFile(statePath, yaml.stringify(updatedStateObj), 'state file');
776
-
777
- // ============================================
778
- // Phase 4: Update Run Log
779
- // ============================================
780
-
781
- const runLogContent = safeReadFile(runLogPath, `run log for '${validatedParams.runId}'`);
782
- const completedTime = new Date().toISOString();
783
-
784
- const { updated: updatedRunLog, warnings: runLogWarnings } = updateRunLog(
785
- runLogPath,
786
- runLogContent,
787
- validatedParams,
788
- completedTime
789
- );
790
- allWarnings.push(...runLogWarnings);
791
-
792
- safeWriteFile(runLogPath, updatedRunLog, `run log for '${validatedParams.runId}'`);
793
-
794
- // ============================================
795
- // Phase 5: Return Result
796
- // ============================================
797
-
798
- return {
799
- success: true,
800
- runId: validatedParams.runId,
801
- completedAt: completedTime,
802
- workItemId,
803
- intentId,
804
- warnings: allWarnings,
805
- };
806
- }