specsmd 0.0.0-dev.41 → 0.0.0-dev.43

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.
@@ -0,0 +1,575 @@
1
+ /**
2
+ * Initialize a new run
3
+ *
4
+ * Creates run record in state.yaml and run folder structure
5
+ *
6
+ * This script is defensive and provides clear error messages for both
7
+ * humans and AI agents to understand failures and how to fix them.
8
+ */
9
+
10
+ import {
11
+ readFileSync,
12
+ writeFileSync,
13
+ mkdirSync,
14
+ existsSync,
15
+ readdirSync,
16
+ rmSync,
17
+ } from 'fs';
18
+ import { join, isAbsolute } from 'path';
19
+ import * as yaml from 'yaml';
20
+
21
+ // =============================================================================
22
+ // Types
23
+ // =============================================================================
24
+
25
+ interface RunInit {
26
+ workItemId: string;
27
+ intentId: string;
28
+ mode: 'autopilot' | 'confirm' | 'validate';
29
+ }
30
+
31
+ interface WorkItem {
32
+ id: string;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ interface Intent {
37
+ id: string;
38
+ work_items?: WorkItem[];
39
+ [key: string]: unknown;
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 State {
51
+ project?: { name?: string };
52
+ intents?: Intent[];
53
+ active_run?: ActiveRun | null;
54
+ }
55
+
56
+ // =============================================================================
57
+ // Custom Error Class
58
+ // =============================================================================
59
+
60
+ export class FireError extends Error {
61
+ constructor(
62
+ message: string,
63
+ public readonly code: string,
64
+ public readonly suggestion: string
65
+ ) {
66
+ super(`FIRE Error [${code}]: ${message} ${suggestion}`);
67
+ this.name = 'FireError';
68
+ }
69
+ }
70
+
71
+ // =============================================================================
72
+ // Validation Helpers
73
+ // =============================================================================
74
+
75
+ const VALID_MODES = ['autopilot', 'confirm', 'validate'] as const;
76
+
77
+ function validateRootPath(rootPath: unknown): asserts rootPath is string {
78
+ if (rootPath === undefined || rootPath === null) {
79
+ throw new FireError(
80
+ 'rootPath is required but was not provided.',
81
+ 'INIT_001',
82
+ 'Ensure the calling code passes a valid project root path.'
83
+ );
84
+ }
85
+
86
+ if (typeof rootPath !== 'string') {
87
+ throw new FireError(
88
+ `rootPath must be a string, but received ${typeof rootPath}.`,
89
+ 'INIT_002',
90
+ 'Ensure the calling code passes a string path.'
91
+ );
92
+ }
93
+
94
+ if (rootPath.trim() === '') {
95
+ throw new FireError(
96
+ 'rootPath cannot be empty.',
97
+ 'INIT_003',
98
+ 'Provide a valid project root path.'
99
+ );
100
+ }
101
+
102
+ if (!isAbsolute(rootPath)) {
103
+ throw new FireError(
104
+ `rootPath must be an absolute path, but received relative path: "${rootPath}".`,
105
+ 'INIT_004',
106
+ 'Convert the path to absolute using path.resolve() before calling initRun().'
107
+ );
108
+ }
109
+
110
+ if (!existsSync(rootPath)) {
111
+ throw new FireError(
112
+ `Project root directory does not exist: "${rootPath}".`,
113
+ 'INIT_005',
114
+ 'Verify the path is correct and the directory exists.'
115
+ );
116
+ }
117
+ }
118
+
119
+ function validateParams(params: unknown): asserts params is RunInit {
120
+ if (params === undefined || params === null) {
121
+ throw new FireError(
122
+ 'params object is required but was not provided.',
123
+ 'INIT_010',
124
+ 'Pass a params object with workItemId, intentId, and mode.'
125
+ );
126
+ }
127
+
128
+ if (typeof params !== 'object') {
129
+ throw new FireError(
130
+ `params must be an object, but received ${typeof params}.`,
131
+ 'INIT_011',
132
+ 'Pass a params object with workItemId, intentId, and mode.'
133
+ );
134
+ }
135
+
136
+ const p = params as Record<string, unknown>;
137
+
138
+ // Validate workItemId
139
+ if (p.workItemId === undefined || p.workItemId === null) {
140
+ throw new FireError(
141
+ 'workItemId is required but was not provided.',
142
+ 'INIT_012',
143
+ 'Include workItemId in the params object (e.g., "WI-001").'
144
+ );
145
+ }
146
+
147
+ if (typeof p.workItemId !== 'string' || p.workItemId.trim() === '') {
148
+ throw new FireError(
149
+ 'workItemId must be a non-empty string.',
150
+ 'INIT_013',
151
+ 'Provide a valid work item ID (e.g., "WI-001").'
152
+ );
153
+ }
154
+
155
+ // Validate intentId
156
+ if (p.intentId === undefined || p.intentId === null) {
157
+ throw new FireError(
158
+ 'intentId is required but was not provided.',
159
+ 'INIT_014',
160
+ 'Include intentId in the params object (e.g., "INT-001").'
161
+ );
162
+ }
163
+
164
+ if (typeof p.intentId !== 'string' || p.intentId.trim() === '') {
165
+ throw new FireError(
166
+ 'intentId must be a non-empty string.',
167
+ 'INIT_015',
168
+ 'Provide a valid intent ID (e.g., "INT-001").'
169
+ );
170
+ }
171
+
172
+ // Validate mode
173
+ if (p.mode === undefined || p.mode === null) {
174
+ throw new FireError(
175
+ 'mode is required but was not provided.',
176
+ 'INIT_016',
177
+ `Include mode in the params object. Valid values: ${VALID_MODES.join(', ')}.`
178
+ );
179
+ }
180
+
181
+ if (!VALID_MODES.includes(p.mode as (typeof VALID_MODES)[number])) {
182
+ throw new FireError(
183
+ `Invalid mode: "${p.mode}".`,
184
+ 'INIT_017',
185
+ `Use one of the valid modes: ${VALID_MODES.join(', ')}.`
186
+ );
187
+ }
188
+ }
189
+
190
+ function validateStateStructure(state: unknown, statePath: string): asserts state is State {
191
+ if (state === undefined || state === null) {
192
+ throw new FireError(
193
+ `state.yaml is empty or invalid at: "${statePath}".`,
194
+ 'INIT_020',
195
+ 'The state.yaml file must contain valid YAML content. Run /specsmd-fire to reinitialize if needed.'
196
+ );
197
+ }
198
+
199
+ if (typeof state !== 'object') {
200
+ throw new FireError(
201
+ `state.yaml must contain a YAML object, but found ${typeof state}.`,
202
+ 'INIT_021',
203
+ 'Check state.yaml structure. It should be a YAML object with project, intents, and active_run fields.'
204
+ );
205
+ }
206
+
207
+ const s = state as Record<string, unknown>;
208
+
209
+ // Validate intents array exists (optional but needed for validation)
210
+ if (s.intents !== undefined && !Array.isArray(s.intents)) {
211
+ throw new FireError(
212
+ 'state.yaml "intents" field must be an array.',
213
+ 'INIT_022',
214
+ 'Check state.yaml structure. The "intents" field should be an array of intent objects.'
215
+ );
216
+ }
217
+ }
218
+
219
+ // =============================================================================
220
+ // State File Operations
221
+ // =============================================================================
222
+
223
+ function readStateFile(statePath: string): State {
224
+ // Check if .specs-fire directory exists
225
+ const specsFireDir = join(statePath, '..');
226
+ if (!existsSync(specsFireDir)) {
227
+ throw new FireError(
228
+ `.specs-fire directory not found at: "${specsFireDir}".`,
229
+ 'INIT_030',
230
+ 'Run /specsmd-fire to initialize the FIRE project first.'
231
+ );
232
+ }
233
+
234
+ // Check if state.yaml exists
235
+ if (!existsSync(statePath)) {
236
+ throw new FireError(
237
+ `state.yaml not found at: "${statePath}".`,
238
+ 'INIT_031',
239
+ 'Run /specsmd-fire to initialize the FIRE project first.'
240
+ );
241
+ }
242
+
243
+ // Read state file
244
+ let stateContent: string;
245
+ try {
246
+ stateContent = readFileSync(statePath, 'utf8');
247
+ } catch (error) {
248
+ const err = error as NodeJS.ErrnoException;
249
+ if (err.code === 'EACCES') {
250
+ throw new FireError(
251
+ `Permission denied reading state.yaml at: "${statePath}".`,
252
+ 'INIT_032',
253
+ 'Check file permissions. Ensure the current user has read access.'
254
+ );
255
+ }
256
+ throw new FireError(
257
+ `Failed to read state.yaml at: "${statePath}". System error: ${err.message}`,
258
+ 'INIT_033',
259
+ 'Check if the file is locked by another process or if there are disk issues.'
260
+ );
261
+ }
262
+
263
+ // Parse YAML
264
+ let state: unknown;
265
+ try {
266
+ state = yaml.parse(stateContent);
267
+ } catch (error) {
268
+ const err = error as Error;
269
+ throw new FireError(
270
+ `state.yaml contains invalid YAML at: "${statePath}". Parse error: ${err.message}`,
271
+ 'INIT_034',
272
+ 'Fix the YAML syntax in state.yaml or run /specsmd-fire to reinitialize.'
273
+ );
274
+ }
275
+
276
+ validateStateStructure(state, statePath);
277
+ return state;
278
+ }
279
+
280
+ function writeStateFile(statePath: string, state: State): void {
281
+ try {
282
+ const yamlContent = yaml.stringify(state);
283
+ writeFileSync(statePath, yamlContent, 'utf8');
284
+ } catch (error) {
285
+ const err = error as NodeJS.ErrnoException;
286
+ if (err.code === 'EACCES') {
287
+ throw new FireError(
288
+ `Permission denied writing to state.yaml at: "${statePath}".`,
289
+ 'INIT_040',
290
+ 'Check file permissions. Ensure the current user has write access.'
291
+ );
292
+ }
293
+ if (err.code === 'ENOSPC') {
294
+ throw new FireError(
295
+ `Disk full - cannot write to state.yaml at: "${statePath}".`,
296
+ 'INIT_041',
297
+ 'Free up disk space and try again.'
298
+ );
299
+ }
300
+ throw new FireError(
301
+ `Failed to write state.yaml at: "${statePath}". System error: ${err.message}`,
302
+ 'INIT_042',
303
+ 'Check if the file is locked by another process or if there are disk issues.'
304
+ );
305
+ }
306
+ }
307
+
308
+ // =============================================================================
309
+ // Run Operations
310
+ // =============================================================================
311
+
312
+ function checkForActiveRun(state: State): void {
313
+ if (state.active_run && state.active_run.id) {
314
+ throw new FireError(
315
+ `An active run already exists: "${state.active_run.id}" for work item "${state.active_run.work_item}".`,
316
+ 'INIT_050',
317
+ 'Complete or cancel the existing run first using /run-complete or /run-cancel, then start a new run.'
318
+ );
319
+ }
320
+ }
321
+
322
+ function validateIntentAndWorkItem(state: State, intentId: string, workItemId: string): void {
323
+ // If no intents defined, we can't validate but we'll allow it (might be added later)
324
+ if (!state.intents || state.intents.length === 0) {
325
+ // Warning: proceeding without validation
326
+ return;
327
+ }
328
+
329
+ // Find the intent
330
+ const intent = state.intents.find((i) => i.id === intentId);
331
+ if (!intent) {
332
+ const availableIntents = state.intents.map((i) => i.id).join(', ');
333
+ throw new FireError(
334
+ `Intent "${intentId}" not found in state.yaml.`,
335
+ 'INIT_051',
336
+ `Available intents: ${availableIntents || '(none)'}. Create the intent first or use an existing one.`
337
+ );
338
+ }
339
+
340
+ // Find the work item within the intent
341
+ if (intent.work_items && intent.work_items.length > 0) {
342
+ const workItem = intent.work_items.find((w) => w.id === workItemId);
343
+ if (!workItem) {
344
+ const availableWorkItems = intent.work_items.map((w) => w.id).join(', ');
345
+ throw new FireError(
346
+ `Work item "${workItemId}" not found in intent "${intentId}".`,
347
+ 'INIT_052',
348
+ `Available work items in this intent: ${availableWorkItems || '(none)'}. Create the work item first or use an existing one.`
349
+ );
350
+ }
351
+ }
352
+ }
353
+
354
+ function generateRunId(runsPath: string): string {
355
+ // Create runs directory if it doesn't exist
356
+ if (!existsSync(runsPath)) {
357
+ try {
358
+ mkdirSync(runsPath, { recursive: true });
359
+ } catch (error) {
360
+ const err = error as NodeJS.ErrnoException;
361
+ throw new FireError(
362
+ `Failed to create runs directory at: "${runsPath}". System error: ${err.message}`,
363
+ 'INIT_060',
364
+ 'Check directory permissions and disk space.'
365
+ );
366
+ }
367
+ return 'run-001'; // First run
368
+ }
369
+
370
+ // Read existing runs
371
+ let entries: string[];
372
+ try {
373
+ entries = readdirSync(runsPath);
374
+ } catch (error) {
375
+ const err = error as NodeJS.ErrnoException;
376
+ throw new FireError(
377
+ `Failed to read runs directory at: "${runsPath}". System error: ${err.message}`,
378
+ 'INIT_061',
379
+ 'Check directory permissions.'
380
+ );
381
+ }
382
+
383
+ // Find the highest run number
384
+ const runNumbers = entries
385
+ .filter((f) => /^run-\d{3,}$/.test(f))
386
+ .map((f) => parseInt(f.replace('run-', ''), 10))
387
+ .filter((n) => !isNaN(n));
388
+
389
+ const maxNum = runNumbers.length > 0 ? Math.max(...runNumbers) : 0;
390
+ const nextNum = maxNum + 1;
391
+
392
+ return `run-${String(nextNum).padStart(3, '0')}`;
393
+ }
394
+
395
+ function createRunFolder(runPath: string): void {
396
+ try {
397
+ mkdirSync(runPath, { recursive: true });
398
+ } catch (error) {
399
+ const err = error as NodeJS.ErrnoException;
400
+ if (err.code === 'EACCES') {
401
+ throw new FireError(
402
+ `Permission denied creating run folder at: "${runPath}".`,
403
+ 'INIT_070',
404
+ 'Check directory permissions. Ensure the current user has write access.'
405
+ );
406
+ }
407
+ if (err.code === 'ENOSPC') {
408
+ throw new FireError(
409
+ `Disk full - cannot create run folder at: "${runPath}".`,
410
+ 'INIT_071',
411
+ 'Free up disk space and try again.'
412
+ );
413
+ }
414
+ throw new FireError(
415
+ `Failed to create run folder at: "${runPath}". System error: ${err.message}`,
416
+ 'INIT_072',
417
+ 'Check if the path is valid and there are no disk issues.'
418
+ );
419
+ }
420
+ }
421
+
422
+ function createRunLog(runPath: string, runId: string, params: RunInit, startTime: string): void {
423
+ const runLog = `---
424
+ id: ${runId}
425
+ work_item: ${params.workItemId}
426
+ intent: ${params.intentId}
427
+ mode: ${params.mode}
428
+ status: in_progress
429
+ started: ${startTime}
430
+ completed: null
431
+ ---
432
+
433
+ # Run: ${runId}
434
+
435
+ ## Work Item
436
+ ${params.workItemId}
437
+
438
+ ## Files Created
439
+ (none yet)
440
+
441
+ ## Files Modified
442
+ (none yet)
443
+
444
+ ## Decisions
445
+ (none yet)
446
+ `;
447
+
448
+ const runLogPath = join(runPath, 'run.md');
449
+ try {
450
+ writeFileSync(runLogPath, runLog, 'utf8');
451
+ } catch (error) {
452
+ const err = error as NodeJS.ErrnoException;
453
+ throw new FireError(
454
+ `Failed to create run.md at: "${runLogPath}". System error: ${err.message}`,
455
+ 'INIT_080',
456
+ 'Check file permissions and disk space.'
457
+ );
458
+ }
459
+ }
460
+
461
+ function rollbackRun(runPath: string): void {
462
+ try {
463
+ if (existsSync(runPath)) {
464
+ rmSync(runPath, { recursive: true, force: true });
465
+ }
466
+ } catch {
467
+ // Best effort rollback - ignore errors
468
+ }
469
+ }
470
+
471
+ // =============================================================================
472
+ // Main Export
473
+ // =============================================================================
474
+
475
+ /**
476
+ * Initialize a new FIRE run
477
+ *
478
+ * @param rootPath - Absolute path to the project root
479
+ * @param params - Run initialization parameters
480
+ * @returns The generated run ID (e.g., "run-001")
481
+ * @throws {FireError} If validation fails or file operations fail
482
+ *
483
+ * @example
484
+ * ```typescript
485
+ * const runId = initRun('/path/to/project', {
486
+ * workItemId: 'WI-001',
487
+ * intentId: 'INT-001',
488
+ * mode: 'autopilot'
489
+ * });
490
+ * console.log(`Started run: ${runId}`);
491
+ * ```
492
+ */
493
+ export function initRun(rootPath: string, params: RunInit): string {
494
+ // ==========================================================================
495
+ // Step 1: Validate inputs
496
+ // ==========================================================================
497
+ validateRootPath(rootPath);
498
+ validateParams(params);
499
+
500
+ // ==========================================================================
501
+ // Step 2: Set up paths
502
+ // ==========================================================================
503
+ const statePath = join(rootPath, '.specs-fire', 'state.yaml');
504
+ const runsPath = join(rootPath, '.specs-fire', 'runs');
505
+
506
+ // ==========================================================================
507
+ // Step 3: Read and validate current state
508
+ // ==========================================================================
509
+ const state = readStateFile(statePath);
510
+
511
+ // ==========================================================================
512
+ // Step 4: Check for existing active run
513
+ // ==========================================================================
514
+ checkForActiveRun(state);
515
+
516
+ // ==========================================================================
517
+ // Step 5: Validate intent and work item exist (if intents are defined)
518
+ // ==========================================================================
519
+ validateIntentAndWorkItem(state, params.intentId, params.workItemId);
520
+
521
+ // ==========================================================================
522
+ // Step 6: Generate run ID (using max number, not count)
523
+ // ==========================================================================
524
+ const runId = generateRunId(runsPath);
525
+ const runPath = join(runsPath, runId);
526
+
527
+ // ==========================================================================
528
+ // Step 7: Create run folder
529
+ // ==========================================================================
530
+ createRunFolder(runPath);
531
+
532
+ // ==========================================================================
533
+ // Step 8: Update state with active run
534
+ // ==========================================================================
535
+ const startTime = new Date().toISOString();
536
+ const previousActiveRun = state.active_run; // Save for potential rollback
537
+
538
+ state.active_run = {
539
+ id: runId,
540
+ work_item: params.workItemId,
541
+ intent: params.intentId,
542
+ mode: params.mode,
543
+ started: startTime,
544
+ };
545
+
546
+ // ==========================================================================
547
+ // Step 9: Save state (with rollback on failure)
548
+ // ==========================================================================
549
+ try {
550
+ writeStateFile(statePath, state);
551
+ } catch (error) {
552
+ // Rollback: remove run folder
553
+ rollbackRun(runPath);
554
+ throw error;
555
+ }
556
+
557
+ // ==========================================================================
558
+ // Step 10: Create run log (with rollback on failure)
559
+ // ==========================================================================
560
+ try {
561
+ createRunLog(runPath, runId, params, startTime);
562
+ } catch (error) {
563
+ // Rollback: restore previous state and remove run folder
564
+ state.active_run = previousActiveRun ?? null;
565
+ try {
566
+ writeStateFile(statePath, state);
567
+ } catch {
568
+ // State file write failed - log but continue with main error
569
+ }
570
+ rollbackRun(runPath);
571
+ throw error;
572
+ }
573
+
574
+ return runId;
575
+ }
@@ -0,0 +1,94 @@
1
+ # Skill: Run Status
2
+
3
+ Display current run status and progress.
4
+
5
+ ---
6
+
7
+ ## Trigger
8
+
9
+ - User asks about run status
10
+ - During long-running execution
11
+
12
+ ---
13
+
14
+ ## Workflow
15
+
16
+ ```xml
17
+ <skill name="run-status">
18
+
19
+ <step n="1" title="Check Active Run">
20
+ <action>Read state.yaml for active_run</action>
21
+ <check if="no active run">
22
+ <output>
23
+ No active run. Last completed run: {last-run-id}
24
+ </output>
25
+ <stop/>
26
+ </check>
27
+ </step>
28
+
29
+ <step n="2" title="Display Status">
30
+ <output>
31
+ ## Run Status: {run-id}
32
+
33
+ **Work Item**: {title}
34
+ **Intent**: {intent-title}
35
+ **Mode**: {mode}
36
+ **Started**: {started}
37
+ **Duration**: {elapsed}
38
+
39
+ ### Progress
40
+
41
+ - [x] Initialize run
42
+ - [x] Load context
43
+ {checkpoint status}
44
+ - [{status}] Execute implementation
45
+ - [ ] Run tests
46
+ - [ ] Generate walkthrough
47
+
48
+ ### Files Changed So Far
49
+
50
+ Created: {created-count}
51
+ Modified: {modified-count}
52
+
53
+ ### Recent Activity
54
+
55
+ {last 5 actions}
56
+ </output>
57
+ </step>
58
+
59
+ </skill>
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Example Output
65
+
66
+ ```
67
+ ## Run Status: run-003
68
+
69
+ **Work Item**: Add session management
70
+ **Intent**: User Authentication
71
+ **Mode**: confirm
72
+ **Started**: 2026-01-19T10:30:00Z
73
+ **Duration**: 12 minutes
74
+
75
+ ### Progress
76
+
77
+ - [x] Initialize run
78
+ - [x] Load context
79
+ - [x] Plan approved (Checkpoint 1)
80
+ - [~] Execute implementation
81
+ - [ ] Run tests
82
+ - [ ] Generate walkthrough
83
+
84
+ ### Files Changed So Far
85
+
86
+ Created: 2
87
+ Modified: 1
88
+
89
+ ### Recent Activity
90
+
91
+ - Created src/auth/session.ts
92
+ - Created src/auth/session.test.ts
93
+ - Modified src/auth/index.ts
94
+ ```