specsmd 0.0.0-dev.40 → 0.0.0-dev.42
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/flows/aidlc/skills/construction/prototype-apply.md +305 -0
- package/flows/aidlc/skills/inception/vibe-to-spec.md +406 -0
- package/flows/fire/README.md +130 -0
- package/flows/fire/agents/builder/agent.md +192 -0
- package/flows/fire/agents/builder/skills/run-execute/SKILL.md +196 -0
- package/flows/fire/agents/builder/skills/run-execute/scripts/complete-run.ts +806 -0
- package/flows/fire/agents/builder/skills/run-execute/scripts/init-run.ts +575 -0
- package/flows/fire/agents/builder/skills/run-status/SKILL.md +94 -0
- package/flows/fire/agents/builder/skills/walkthrough-generate/SKILL.md +140 -0
- package/flows/fire/agents/builder/skills/walkthrough-generate/scripts/render-walkthrough.ts +755 -0
- package/flows/fire/agents/orchestrator/agent.md +113 -0
- package/flows/fire/agents/orchestrator/skills/project-init/SKILL.md +124 -0
- package/flows/fire/agents/orchestrator/skills/route/SKILL.md +126 -0
- package/flows/fire/agents/orchestrator/skills/status/SKILL.md +99 -0
- package/flows/fire/agents/planner/agent.md +122 -0
- package/flows/fire/agents/planner/skills/design-doc-generate/SKILL.md +211 -0
- package/flows/fire/agents/planner/skills/intent-capture/SKILL.md +142 -0
- package/flows/fire/agents/planner/skills/work-item-decompose/SKILL.md +174 -0
- package/flows/fire/commands/fire-builder.md +56 -0
- package/flows/fire/commands/fire-planner.md +48 -0
- package/flows/fire/commands/fire.md +46 -0
- package/flows/fire/fire-config.yaml +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,806 @@
|
|
|
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
|
+
}
|