specsmd 0.1.44 → 0.1.46

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.
@@ -204,6 +204,8 @@ Supports both single-item and multi-item (batch/wide) runs.
204
204
  <action>Save plan IMMEDIATELY using template: templates/plan.md.hbs</action>
205
205
  <action>Write to: .specs-fire/runs/{run-id}/plan.md</action>
206
206
  <output>Plan saved to: .specs-fire/runs/{run-id}/plan.md</output>
207
+ <action>Mark checkpoint as waiting:</action>
208
+ <code>node scripts/update-checkpoint.cjs {rootPath} {runId} awaiting_approval --checkpoint=plan</code>
207
209
 
208
210
  <checkpoint>
209
211
  <template_output section="plan">
@@ -232,6 +234,8 @@ Supports both single-item and multi-item (batch/wide) runs.
232
234
  <action>Update plan.md with changes</action>
233
235
  <goto step="3b"/>
234
236
  </check>
237
+ <action>Mark checkpoint approved:</action>
238
+ <code>node scripts/update-checkpoint.cjs {rootPath} {runId} approved --checkpoint=plan</code>
235
239
  <goto step="5"/>
236
240
  </step>
237
241
 
@@ -242,6 +246,8 @@ Supports both single-item and multi-item (batch/wide) runs.
242
246
  <action>Write to: .specs-fire/runs/{run-id}/plan.md</action>
243
247
  <action>Include reference to design doc in plan</action>
244
248
  <output>Plan saved to: .specs-fire/runs/{run-id}/plan.md</output>
249
+ <action>Mark checkpoint as waiting:</action>
250
+ <code>node scripts/update-checkpoint.cjs {rootPath} {runId} awaiting_approval --checkpoint=plan</code>
245
251
 
246
252
  <checkpoint>
247
253
  <template_output section="plan">
@@ -270,6 +276,8 @@ Supports both single-item and multi-item (batch/wide) runs.
270
276
  <action>Update plan.md with changes</action>
271
277
  <goto step="3c"/>
272
278
  </check>
279
+ <action>Mark checkpoint approved:</action>
280
+ <code>node scripts/update-checkpoint.cjs {rootPath} {runId} approved --checkpoint=plan</code>
273
281
  <goto step="5"/>
274
282
  </step>
275
283
 
@@ -520,6 +528,7 @@ Supports both single-item and multi-item (batch/wide) runs.
520
528
  | Script | Purpose | Usage |
521
529
  |--------|---------|-------|
522
530
  | `scripts/init-run.cjs` | Initialize run record and folder | Creates run.md with all work items |
531
+ | `scripts/update-checkpoint.cjs` | Mark approval gate state for active item | `awaiting_approval` / `approved` |
523
532
  | `scripts/update-phase.cjs` | Update current work item's phase | `node scripts/update-phase.cjs {rootPath} {runId} {phase}` |
524
533
  | `scripts/complete-run.cjs` | Finalize run and update state | `--complete-item` or `--complete-run` |
525
534
 
@@ -305,6 +305,9 @@ function updateRunLog(runLogPath, activeRun, params, completedTime, isFullComple
305
305
  intent: item.intent,
306
306
  mode: item.mode,
307
307
  status: item.status,
308
+ current_phase: item.current_phase || null,
309
+ checkpoint_state: item.checkpoint_state || null,
310
+ current_checkpoint: item.current_checkpoint || null,
308
311
  }));
309
312
  }
310
313
 
@@ -427,6 +430,12 @@ function completeCurrentItem(rootPath, runId, params = {}) {
427
430
  if (workItems[i].id === currentItemId) {
428
431
  workItems[i].status = 'completed';
429
432
  workItems[i].completed_at = completedTime;
433
+ if (workItems[i].mode === 'confirm' || workItems[i].mode === 'validate') {
434
+ workItems[i].checkpoint_state = 'approved';
435
+ workItems[i].current_checkpoint = workItems[i].current_checkpoint || 'plan';
436
+ } else {
437
+ workItems[i].checkpoint_state = workItems[i].checkpoint_state || 'not_required';
438
+ }
430
439
  currentItemIndex = i;
431
440
  break;
432
441
  }
@@ -446,6 +455,10 @@ function completeCurrentItem(rootPath, runId, params = {}) {
446
455
  if (workItems[i].status === 'pending') {
447
456
  workItems[i].status = 'in_progress';
448
457
  workItems[i].current_phase = 'plan';
458
+ workItems[i].checkpoint_state = 'none';
459
+ workItems[i].current_checkpoint = (workItems[i].mode === 'confirm' || workItems[i].mode === 'validate')
460
+ ? 'plan'
461
+ : null;
449
462
  nextItem = workItems[i];
450
463
  break;
451
464
  }
@@ -543,6 +556,12 @@ function completeRun(rootPath, runId, params = {}) {
543
556
  item.status = 'completed';
544
557
  item.completed_at = completedTime;
545
558
  }
559
+ if (item.mode === 'confirm' || item.mode === 'validate') {
560
+ item.checkpoint_state = 'approved';
561
+ item.current_checkpoint = item.current_checkpoint || 'plan';
562
+ } else {
563
+ item.checkpoint_state = item.checkpoint_state || 'not_required';
564
+ }
546
565
  }
547
566
 
548
567
  activeRun.work_items = workItems;
@@ -233,7 +233,11 @@ function createRunLog(runPath, runId, workItems, scope, startTime) {
233
233
  // Format work items for run.md
234
234
  const workItemsList = workItems.map((item, index) => {
235
235
  const status = index === 0 ? 'in_progress' : 'pending';
236
- return ` - id: ${item.id}\n intent: ${item.intent}\n mode: ${item.mode}\n status: ${status}`;
236
+ const currentPhase = index === 0 ? 'plan' : 'null';
237
+ const currentCheckpoint = index === 0 && (item.mode === 'confirm' || item.mode === 'validate')
238
+ ? 'plan'
239
+ : 'null';
240
+ return ` - id: ${item.id}\n intent: ${item.intent}\n mode: ${item.mode}\n status: ${status}\n current_phase: ${currentPhase}\n checkpoint_state: none\n current_checkpoint: ${currentCheckpoint}`;
237
241
  }).join('\n');
238
242
 
239
243
  const currentItem = workItems[0];
@@ -346,6 +350,10 @@ function initRun(rootPath, workItems, scope) {
346
350
  mode: item.mode,
347
351
  status: index === 0 ? 'in_progress' : 'pending',
348
352
  current_phase: index === 0 ? 'plan' : null,
353
+ checkpoint_state: 'none',
354
+ current_checkpoint: (index === 0 && (item.mode === 'confirm' || item.mode === 'validate'))
355
+ ? 'plan'
356
+ : null,
349
357
  }));
350
358
 
351
359
  // Add to active runs list (supports multiple parallel runs)
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * FIRE Checkpoint State Update Script
5
+ *
6
+ * Tracks explicit approval-gate state for the active work item in a run.
7
+ *
8
+ * Usage:
9
+ * node update-checkpoint.cjs <rootPath> <runId> <checkpointState> [--item=<workItemId>] [--checkpoint=<name>]
10
+ *
11
+ * Examples:
12
+ * node update-checkpoint.cjs /project run-001 awaiting_approval --checkpoint=plan
13
+ * node update-checkpoint.cjs /project run-001 approved
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const yaml = require('yaml');
19
+
20
+ const VALID_STATES = ['awaiting_approval', 'approved', 'none', 'not_required'];
21
+
22
+ function fireError(message, code, suggestion) {
23
+ const err = new Error(`FIRE Error [${code}]: ${message} ${suggestion}`);
24
+ err.code = code;
25
+ err.suggestion = suggestion;
26
+ return err;
27
+ }
28
+
29
+ function normalizeCheckpointState(value) {
30
+ const normalized = String(value || '').toLowerCase().trim().replace(/[\s-]+/g, '_');
31
+ const map = {
32
+ waiting: 'awaiting_approval',
33
+ awaiting: 'awaiting_approval',
34
+ awaiting_approval: 'awaiting_approval',
35
+ pending_approval: 'awaiting_approval',
36
+ approval_needed: 'awaiting_approval',
37
+ approval_required: 'awaiting_approval',
38
+ approved: 'approved',
39
+ confirmed: 'approved',
40
+ accepted: 'approved',
41
+ resumed: 'approved',
42
+ none: 'none',
43
+ clear: 'none',
44
+ cleared: 'none',
45
+ reset: 'none',
46
+ not_required: 'not_required',
47
+ n_a: 'not_required',
48
+ na: 'not_required',
49
+ skipped: 'not_required'
50
+ };
51
+
52
+ return map[normalized] || null;
53
+ }
54
+
55
+ function validateInputs(rootPath, runId, checkpointState) {
56
+ if (!rootPath || typeof rootPath !== 'string' || rootPath.trim() === '') {
57
+ throw fireError('rootPath is required.', 'CHECKPOINT_001', 'Provide a valid project root path.');
58
+ }
59
+
60
+ if (!runId || typeof runId !== 'string' || runId.trim() === '') {
61
+ throw fireError('runId is required.', 'CHECKPOINT_002', 'Provide the run ID.');
62
+ }
63
+
64
+ const normalizedState = normalizeCheckpointState(checkpointState);
65
+ if (!normalizedState || !VALID_STATES.includes(normalizedState)) {
66
+ throw fireError(
67
+ `Invalid checkpointState: "${checkpointState}".`,
68
+ 'CHECKPOINT_003',
69
+ `Valid states are: ${VALID_STATES.join(', ')}`
70
+ );
71
+ }
72
+
73
+ if (!fs.existsSync(rootPath)) {
74
+ throw fireError(
75
+ `Project root not found: "${rootPath}".`,
76
+ 'CHECKPOINT_004',
77
+ 'Ensure the path exists and is accessible.'
78
+ );
79
+ }
80
+
81
+ return normalizedState;
82
+ }
83
+
84
+ function validateFireProject(rootPath) {
85
+ const fireDir = path.join(rootPath, '.specs-fire');
86
+ const statePath = path.join(fireDir, 'state.yaml');
87
+
88
+ if (!fs.existsSync(fireDir)) {
89
+ throw fireError(
90
+ `FIRE project not initialized at: "${rootPath}".`,
91
+ 'CHECKPOINT_010',
92
+ 'Run fire-init first to initialize the project.'
93
+ );
94
+ }
95
+
96
+ if (!fs.existsSync(statePath)) {
97
+ throw fireError(
98
+ `State file not found at: "${statePath}".`,
99
+ 'CHECKPOINT_011',
100
+ 'The project may be corrupted. Try re-initializing.'
101
+ );
102
+ }
103
+
104
+ return { statePath };
105
+ }
106
+
107
+ function readState(statePath) {
108
+ try {
109
+ const content = fs.readFileSync(statePath, 'utf8');
110
+ const state = yaml.parse(content);
111
+ if (!state || typeof state !== 'object') {
112
+ throw fireError('State file is empty or invalid.', 'CHECKPOINT_020', 'Check state.yaml format.');
113
+ }
114
+ return state;
115
+ } catch (err) {
116
+ if (err.code && err.code.startsWith('CHECKPOINT_')) throw err;
117
+ throw fireError(
118
+ `Failed to read state file: ${err.message}`,
119
+ 'CHECKPOINT_021',
120
+ 'Check file permissions and YAML syntax.'
121
+ );
122
+ }
123
+ }
124
+
125
+ function writeState(statePath, state) {
126
+ try {
127
+ fs.writeFileSync(statePath, yaml.stringify(state));
128
+ } catch (err) {
129
+ throw fireError(
130
+ `Failed to write state file: ${err.message}`,
131
+ 'CHECKPOINT_022',
132
+ 'Check file permissions and disk space.'
133
+ );
134
+ }
135
+ }
136
+
137
+ function updateCheckpoint(rootPath, runId, checkpointState, options = {}) {
138
+ const normalizedState = validateInputs(rootPath, runId, checkpointState);
139
+ const { statePath } = validateFireProject(rootPath);
140
+ const state = readState(statePath);
141
+
142
+ const activeRuns = state.runs?.active || [];
143
+ const runIndex = activeRuns.findIndex((run) => run.id === runId);
144
+ if (runIndex === -1) {
145
+ throw fireError(
146
+ `Run "${runId}" not found in active runs.`,
147
+ 'CHECKPOINT_030',
148
+ 'The run may have already been completed or was never started.'
149
+ );
150
+ }
151
+
152
+ const activeRun = activeRuns[runIndex];
153
+ const workItems = Array.isArray(activeRun.work_items) ? activeRun.work_items : [];
154
+ const targetItemId = options.itemId || activeRun.current_item;
155
+ if (!targetItemId) {
156
+ throw fireError(
157
+ `Run "${runId}" has no current item.`,
158
+ 'CHECKPOINT_031',
159
+ 'Specify --item=<workItemId> explicitly.'
160
+ );
161
+ }
162
+
163
+ const itemIndex = workItems.findIndex((item) => item.id === targetItemId);
164
+ if (itemIndex === -1) {
165
+ throw fireError(
166
+ `Work item "${targetItemId}" not found in run "${runId}".`,
167
+ 'CHECKPOINT_032',
168
+ 'Check the work item ID or run state.'
169
+ );
170
+ }
171
+
172
+ const item = workItems[itemIndex];
173
+ const previousState = item.checkpoint_state || null;
174
+ item.checkpoint_state = normalizedState;
175
+
176
+ if (typeof options.checkpoint === 'string' && options.checkpoint.trim() !== '') {
177
+ item.current_checkpoint = options.checkpoint.trim();
178
+ } else if (!item.current_checkpoint && (normalizedState === 'awaiting_approval' || normalizedState === 'approved')) {
179
+ item.current_checkpoint = 'plan';
180
+ }
181
+
182
+ if (!item.current_phase && normalizedState === 'awaiting_approval') {
183
+ item.current_phase = 'plan';
184
+ }
185
+
186
+ activeRun.work_items = workItems;
187
+ state.runs.active[runIndex] = activeRun;
188
+ writeState(statePath, state);
189
+
190
+ return {
191
+ success: true,
192
+ runId,
193
+ workItemId: targetItemId,
194
+ checkpointState: normalizedState,
195
+ previousCheckpointState: previousState,
196
+ currentCheckpoint: item.current_checkpoint || null
197
+ };
198
+ }
199
+
200
+ function parseOptions(argv) {
201
+ const options = {};
202
+ for (const arg of argv) {
203
+ if (arg.startsWith('--item=')) {
204
+ options.itemId = arg.slice('--item='.length);
205
+ } else if (arg.startsWith('--checkpoint=')) {
206
+ options.checkpoint = arg.slice('--checkpoint='.length);
207
+ } else {
208
+ throw fireError(
209
+ `Unknown option: ${arg}`,
210
+ 'CHECKPOINT_033',
211
+ 'Use --item=<workItemId> or --checkpoint=<name>.'
212
+ );
213
+ }
214
+ }
215
+ return options;
216
+ }
217
+
218
+ function printUsage() {
219
+ console.error('Usage:');
220
+ console.error(' node update-checkpoint.cjs <rootPath> <runId> <checkpointState> [--item=<workItemId>] [--checkpoint=<name>]');
221
+ console.error('');
222
+ console.error('checkpointState:');
223
+ console.error(` ${VALID_STATES.join(', ')}`);
224
+ console.error('');
225
+ console.error('Examples:');
226
+ console.error(' node update-checkpoint.cjs /project run-001 awaiting_approval --checkpoint=plan');
227
+ console.error(' node update-checkpoint.cjs /project run-001 approved');
228
+ }
229
+
230
+ if (require.main === module) {
231
+ const args = process.argv.slice(2);
232
+ if (args.length < 3) {
233
+ printUsage();
234
+ process.exit(1);
235
+ }
236
+
237
+ const [rootPath, runId, checkpointState, ...optionArgs] = args;
238
+
239
+ try {
240
+ const options = parseOptions(optionArgs);
241
+ const result = updateCheckpoint(rootPath, runId, checkpointState, options);
242
+ console.log(JSON.stringify(result, null, 2));
243
+ process.exit(0);
244
+ } catch (err) {
245
+ console.error(err.message);
246
+ process.exit(1);
247
+ }
248
+ }
249
+
250
+ module.exports = {
251
+ VALID_STATES,
252
+ normalizeCheckpointState,
253
+ updateCheckpoint
254
+ };
@@ -171,6 +171,17 @@ function updatePhase(rootPath, runId, phase) {
171
171
  if (item.id === currentItemId) {
172
172
  previousPhase = item.current_phase || 'plan';
173
173
  item.current_phase = phase;
174
+ const mode = String(item.mode || '').toLowerCase();
175
+ const isApprovalMode = mode === 'confirm' || mode === 'validate';
176
+
177
+ if (isApprovalMode && phase !== 'plan') {
178
+ item.checkpoint_state = 'approved';
179
+ if (!item.current_checkpoint) {
180
+ item.current_checkpoint = 'plan';
181
+ }
182
+ } else if (!item.checkpoint_state) {
183
+ item.checkpoint_state = 'none';
184
+ }
174
185
  updated = true;
175
186
  break;
176
187
  }
@@ -78,6 +78,15 @@ function normalizeTimestamp(value) {
78
78
  return String(value);
79
79
  }
80
80
 
81
+ function normalizeCheckpointState(value) {
82
+ if (typeof value !== 'string') {
83
+ return undefined;
84
+ }
85
+
86
+ const normalized = value.toLowerCase().trim().replace(/[\s-]+/g, '_');
87
+ return normalized === '' ? undefined : normalized;
88
+ }
89
+
81
90
  function parseDependencies(raw) {
82
91
  if (Array.isArray(raw)) {
83
92
  return raw.filter((item) => typeof item === 'string' && item.trim() !== '');
@@ -97,7 +106,9 @@ function normalizeRunWorkItem(raw, fallbackIntentId = '') {
97
106
  intentId: fallbackIntentId,
98
107
  mode: 'confirm',
99
108
  status: 'pending',
100
- currentPhase: undefined
109
+ currentPhase: undefined,
110
+ checkpointState: undefined,
111
+ currentCheckpoint: undefined
101
112
  };
102
113
  }
103
114
 
@@ -107,7 +118,9 @@ function normalizeRunWorkItem(raw, fallbackIntentId = '') {
107
118
  intentId: fallbackIntentId,
108
119
  mode: 'confirm',
109
120
  status: 'pending',
110
- currentPhase: undefined
121
+ currentPhase: undefined,
122
+ checkpointState: undefined,
123
+ currentCheckpoint: undefined
111
124
  };
112
125
  }
113
126
 
@@ -121,13 +134,27 @@ function normalizeRunWorkItem(raw, fallbackIntentId = '') {
121
134
  const currentPhase = typeof raw.current_phase === 'string'
122
135
  ? raw.current_phase
123
136
  : (typeof raw.currentPhase === 'string' ? raw.currentPhase : undefined);
137
+ const checkpointState = typeof raw.checkpoint_state === 'string'
138
+ ? raw.checkpoint_state
139
+ : (typeof raw.checkpointState === 'string'
140
+ ? raw.checkpointState
141
+ : (typeof raw.approval_state === 'string'
142
+ ? raw.approval_state
143
+ : (typeof raw.approvalState === 'string' ? raw.approvalState : undefined)));
144
+ const currentCheckpoint = typeof raw.current_checkpoint === 'string'
145
+ ? raw.current_checkpoint
146
+ : (typeof raw.currentCheckpoint === 'string'
147
+ ? raw.currentCheckpoint
148
+ : (typeof raw.checkpoint === 'string' ? raw.checkpoint : undefined));
124
149
 
125
150
  return {
126
151
  id,
127
152
  intentId,
128
153
  mode,
129
154
  status,
130
- currentPhase
155
+ currentPhase,
156
+ checkpointState,
157
+ currentCheckpoint
131
158
  };
132
159
  }
133
160
 
@@ -206,7 +233,18 @@ function normalizeState(rawState) {
206
233
  currentItem: typeof run.current_item === 'string'
207
234
  ? run.current_item
208
235
  : (typeof run.currentItem === 'string' ? run.currentItem : ''),
209
- started: normalizeTimestamp(run.started) || ''
236
+ started: normalizeTimestamp(run.started) || '',
237
+ checkpointState: normalizeCheckpointState(
238
+ run.checkpoint_state
239
+ || run.checkpointState
240
+ || run.approval_state
241
+ || run.approvalState
242
+ ),
243
+ currentCheckpoint: typeof run.current_checkpoint === 'string'
244
+ ? run.current_checkpoint
245
+ : (typeof run.currentCheckpoint === 'string'
246
+ ? run.currentCheckpoint
247
+ : (typeof run.checkpoint === 'string' ? run.checkpoint : undefined))
210
248
  };
211
249
  }).filter(Boolean);
212
250
 
@@ -221,6 +259,17 @@ function normalizeState(rawState) {
221
259
  return {
222
260
  id: typeof run.id === 'string' ? run.id : '',
223
261
  workItems: workItemsRaw.map((item) => normalizeRunWorkItem(item, fallbackIntentId)).filter((item) => item.id !== ''),
262
+ checkpointState: normalizeCheckpointState(
263
+ run.checkpoint_state
264
+ || run.checkpointState
265
+ || run.approval_state
266
+ || run.approvalState
267
+ ),
268
+ currentCheckpoint: typeof run.current_checkpoint === 'string'
269
+ ? run.current_checkpoint
270
+ : (typeof run.currentCheckpoint === 'string'
271
+ ? run.currentCheckpoint
272
+ : (typeof run.checkpoint === 'string' ? run.checkpoint : undefined)),
224
273
  completed: normalizeTimestamp(run.completed) || ''
225
274
  };
226
275
  }).filter(Boolean);
@@ -65,6 +65,21 @@ function listMarkdownFiles(dirPath) {
65
65
  }
66
66
  }
67
67
 
68
+ function getFirstStringValue(record, keys) {
69
+ if (!record || typeof record !== 'object') {
70
+ return undefined;
71
+ }
72
+
73
+ for (const key of keys) {
74
+ const value = record[key];
75
+ if (typeof value === 'string' && value.trim() !== '') {
76
+ return value;
77
+ }
78
+ }
79
+
80
+ return undefined;
81
+ }
82
+
68
83
  function parseRunLog(runLogPath) {
69
84
  const content = readFileSafe(runLogPath);
70
85
  if (!content) {
@@ -73,15 +88,40 @@ function parseRunLog(runLogPath) {
73
88
  workItems: [],
74
89
  currentItem: null,
75
90
  startedAt: undefined,
76
- completedAt: undefined
91
+ completedAt: undefined,
92
+ checkpointState: undefined,
93
+ currentCheckpoint: undefined
77
94
  };
78
95
  }
79
96
 
80
97
  const frontmatter = parseFrontmatter(content);
98
+ const currentItem = getFirstStringValue(frontmatter, ['current_item', 'currentItem', 'work_item', 'workItem']);
99
+ const itemMode = getFirstStringValue(frontmatter, ['mode']);
100
+ const itemStatus = getFirstStringValue(frontmatter, ['status']);
101
+ const itemPhase = getFirstStringValue(frontmatter, ['current_phase', 'currentPhase']);
102
+ const itemCheckpointState = getFirstStringValue(frontmatter, [
103
+ 'checkpoint_state',
104
+ 'checkpointState',
105
+ 'approval_state',
106
+ 'approvalState'
107
+ ]);
108
+ const itemCheckpoint = getFirstStringValue(frontmatter, ['current_checkpoint', 'currentCheckpoint', 'checkpoint']);
109
+
81
110
  const workItemsRaw = Array.isArray(frontmatter.work_items)
82
111
  ? frontmatter.work_items
83
112
  : (Array.isArray(frontmatter.workItems) ? frontmatter.workItems : []);
84
113
 
114
+ if (workItemsRaw.length === 0 && typeof currentItem === 'string' && currentItem !== '') {
115
+ workItemsRaw.push({
116
+ id: currentItem,
117
+ mode: itemMode,
118
+ status: itemStatus,
119
+ current_phase: itemPhase,
120
+ checkpoint_state: itemCheckpointState,
121
+ current_checkpoint: itemCheckpoint
122
+ });
123
+ }
124
+
85
125
  const workItems = workItemsRaw
86
126
  .map((item) => normalizeRunWorkItem(item))
87
127
  .filter((item) => item.id !== '');
@@ -89,16 +129,54 @@ function parseRunLog(runLogPath) {
89
129
  return {
90
130
  scope: normalizeScope(frontmatter.scope),
91
131
  workItems,
92
- currentItem: typeof frontmatter.current_item === 'string'
93
- ? frontmatter.current_item
94
- : (typeof frontmatter.currentItem === 'string' ? frontmatter.currentItem : null),
132
+ currentItem: currentItem || null,
95
133
  startedAt: typeof frontmatter.started === 'string' ? frontmatter.started : undefined,
96
134
  completedAt: typeof frontmatter.completed === 'string'
97
135
  ? frontmatter.completed
98
- : undefined
136
+ : undefined,
137
+ checkpointState: itemCheckpointState,
138
+ currentCheckpoint: itemCheckpoint
99
139
  };
100
140
  }
101
141
 
142
+ function mergeRunWorkItems(primaryItems, fallbackItems) {
143
+ const primary = Array.isArray(primaryItems) ? primaryItems : [];
144
+ const fallback = Array.isArray(fallbackItems) ? fallbackItems : [];
145
+
146
+ if (primary.length === 0) {
147
+ return fallback;
148
+ }
149
+
150
+ if (fallback.length === 0) {
151
+ return primary;
152
+ }
153
+
154
+ const fallbackById = new Map(fallback.map((item) => [item.id, item]));
155
+ const merged = primary.map((item) => {
156
+ const fallbackItem = fallbackById.get(item.id);
157
+ if (!fallbackItem) {
158
+ return item;
159
+ }
160
+
161
+ return {
162
+ ...fallbackItem,
163
+ ...item,
164
+ checkpointState: item.checkpointState || fallbackItem.checkpointState,
165
+ currentCheckpoint: item.currentCheckpoint || fallbackItem.currentCheckpoint,
166
+ currentPhase: item.currentPhase || fallbackItem.currentPhase
167
+ };
168
+ });
169
+
170
+ const knownIds = new Set(merged.map((item) => item.id));
171
+ for (const fallbackItem of fallback) {
172
+ if (!knownIds.has(fallbackItem.id)) {
173
+ merged.push(fallbackItem);
174
+ }
175
+ }
176
+
177
+ return merged;
178
+ }
179
+
102
180
  function scanWorkItems(intentPath, intentId, stateWorkItems, warnings) {
103
181
  const workItemsPath = path.join(intentPath, 'work-items');
104
182
  const fileWorkItemIds = listMarkdownFiles(workItemsPath)
@@ -202,11 +280,15 @@ function scanRuns(rootPath, normalizedState) {
202
280
  const stateActiveRun = stateActiveMap.get(runId);
203
281
  const stateCompletedRun = stateCompletedMap.get(runId);
204
282
 
205
- const workItems = (stateActiveRun?.workItems && stateActiveRun.workItems.length > 0)
283
+ const stateRunWorkItems = (stateActiveRun?.workItems && stateActiveRun.workItems.length > 0)
206
284
  ? stateActiveRun.workItems
207
285
  : ((stateCompletedRun?.workItems && stateCompletedRun.workItems.length > 0)
208
286
  ? stateCompletedRun.workItems
209
- : parsedRunLog.workItems);
287
+ : []);
288
+ const workItems = mergeRunWorkItems(
289
+ stateRunWorkItems.length > 0 ? stateRunWorkItems : parsedRunLog.workItems,
290
+ parsedRunLog.workItems
291
+ );
210
292
 
211
293
  const completedAt = stateCompletedRun?.completed || parsedRunLog.completedAt || undefined;
212
294
 
@@ -215,6 +297,8 @@ function scanRuns(rootPath, normalizedState) {
215
297
  scope: stateActiveRun?.scope || parsedRunLog.scope || 'single',
216
298
  workItems,
217
299
  currentItem: stateActiveRun?.currentItem || parsedRunLog.currentItem || null,
300
+ checkpointState: stateActiveRun?.checkpointState || parsedRunLog.checkpointState,
301
+ currentCheckpoint: stateActiveRun?.currentCheckpoint || parsedRunLog.currentCheckpoint,
218
302
  folderPath,
219
303
  startedAt: stateActiveRun?.started || parsedRunLog.startedAt || '',
220
304
  completedAt: completedAt === 'null' ? undefined : completedAt,
@@ -253,6 +253,313 @@ function getCurrentRun(snapshot) {
253
253
  return activeRuns[0] || null;
254
254
  }
255
255
 
256
+ function normalizeToken(value) {
257
+ if (typeof value !== 'string') {
258
+ return '';
259
+ }
260
+ return value.toLowerCase().trim().replace(/[\s-]+/g, '_');
261
+ }
262
+
263
+ function getCurrentFireWorkItem(run) {
264
+ const workItems = Array.isArray(run?.workItems) ? run.workItems : [];
265
+ if (workItems.length === 0) {
266
+ return null;
267
+ }
268
+ return workItems.find((item) => item.id === run.currentItem)
269
+ || workItems.find((item) => normalizeToken(item?.status) === 'in_progress')
270
+ || workItems[0]
271
+ || null;
272
+ }
273
+
274
+ function readFileTextSafe(filePath) {
275
+ try {
276
+ return fs.readFileSync(filePath, 'utf8');
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ function extractFrontmatterBlock(content) {
283
+ if (typeof content !== 'string') {
284
+ return null;
285
+ }
286
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
287
+ return match ? match[1] : null;
288
+ }
289
+
290
+ function extractFrontmatterValue(frontmatterBlock, key) {
291
+ if (typeof frontmatterBlock !== 'string' || typeof key !== 'string' || key === '') {
292
+ return null;
293
+ }
294
+
295
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
296
+ const expression = new RegExp(`^${escapedKey}\\s*:\\s*(.+)$`, 'mi');
297
+ const match = frontmatterBlock.match(expression);
298
+ if (!match) {
299
+ return null;
300
+ }
301
+
302
+ const raw = String(match[1] || '').trim();
303
+ if (raw === '') {
304
+ return '';
305
+ }
306
+
307
+ return raw
308
+ .replace(/^["']/, '')
309
+ .replace(/["']$/, '')
310
+ .trim();
311
+ }
312
+
313
+ const FIRE_AWAITING_APPROVAL_STATES = new Set([
314
+ 'awaiting_approval',
315
+ 'waiting',
316
+ 'pending_approval',
317
+ 'approval_needed',
318
+ 'approval_required',
319
+ 'checkpoint_pending'
320
+ ]);
321
+
322
+ const FIRE_APPROVED_STATES = new Set([
323
+ 'approved',
324
+ 'confirmed',
325
+ 'accepted',
326
+ 'resumed',
327
+ 'done',
328
+ 'completed',
329
+ 'cleared',
330
+ 'none',
331
+ 'not_required',
332
+ 'skipped'
333
+ ]);
334
+
335
+ function parseFirePlanCheckpointMetadata(run) {
336
+ if (!run || typeof run.folderPath !== 'string' || run.folderPath.trim() === '') {
337
+ return { hasPlan: false, checkpointState: null, checkpoint: null };
338
+ }
339
+
340
+ const planPath = path.join(run.folderPath, 'plan.md');
341
+ if (!fileExists(planPath)) {
342
+ return { hasPlan: false, checkpointState: null, checkpoint: null };
343
+ }
344
+
345
+ const content = readFileTextSafe(planPath);
346
+ const frontmatter = extractFrontmatterBlock(content);
347
+ if (!frontmatter) {
348
+ return { hasPlan: true, checkpointState: null, checkpoint: null };
349
+ }
350
+
351
+ const checkpointState = normalizeToken(
352
+ extractFrontmatterValue(frontmatter, 'checkpoint_state')
353
+ || extractFrontmatterValue(frontmatter, 'checkpointState')
354
+ || extractFrontmatterValue(frontmatter, 'approval_state')
355
+ || extractFrontmatterValue(frontmatter, 'approvalState')
356
+ || ''
357
+ ) || null;
358
+ const checkpoint = extractFrontmatterValue(frontmatter, 'current_checkpoint')
359
+ || extractFrontmatterValue(frontmatter, 'currentCheckpoint')
360
+ || extractFrontmatterValue(frontmatter, 'checkpoint')
361
+ || null;
362
+
363
+ return {
364
+ hasPlan: true,
365
+ checkpointState,
366
+ checkpoint
367
+ };
368
+ }
369
+
370
+ function resolveFireApprovalState(run, currentWorkItem) {
371
+ const itemState = normalizeToken(
372
+ currentWorkItem?.checkpointState
373
+ || currentWorkItem?.checkpoint_state
374
+ || currentWorkItem?.approvalState
375
+ || currentWorkItem?.approval_state
376
+ || ''
377
+ );
378
+ const runState = normalizeToken(
379
+ run?.checkpointState
380
+ || run?.checkpoint_state
381
+ || run?.approvalState
382
+ || run?.approval_state
383
+ || ''
384
+ );
385
+ const planState = parseFirePlanCheckpointMetadata(run);
386
+ const state = itemState || runState || planState.checkpointState || null;
387
+ const checkpoint = currentWorkItem?.currentCheckpoint
388
+ || currentWorkItem?.current_checkpoint
389
+ || run?.currentCheckpoint
390
+ || run?.current_checkpoint
391
+ || planState.checkpoint
392
+ || null;
393
+
394
+ return {
395
+ state,
396
+ checkpoint,
397
+ source: itemState
398
+ ? 'item-state'
399
+ : (runState
400
+ ? 'run-state'
401
+ : (planState.checkpointState ? 'plan-frontmatter' : null))
402
+ };
403
+ }
404
+
405
+ function getFireRunApprovalGate(run, currentWorkItem) {
406
+ const mode = normalizeToken(currentWorkItem?.mode);
407
+ const status = normalizeToken(currentWorkItem?.status);
408
+ if (!['confirm', 'validate'].includes(mode) || status !== 'in_progress') {
409
+ return null;
410
+ }
411
+
412
+ const phase = normalizeToken(getCurrentPhaseLabel(run, currentWorkItem));
413
+ if (phase !== 'plan') {
414
+ return null;
415
+ }
416
+
417
+ const resolvedApproval = resolveFireApprovalState(run, currentWorkItem);
418
+ if (!resolvedApproval.state) {
419
+ return null;
420
+ }
421
+
422
+ if (FIRE_APPROVED_STATES.has(resolvedApproval.state)) {
423
+ return null;
424
+ }
425
+
426
+ if (!FIRE_AWAITING_APPROVAL_STATES.has(resolvedApproval.state)) {
427
+ return null;
428
+ }
429
+
430
+ const modeLabel = String(currentWorkItem?.mode || 'confirm').toUpperCase();
431
+ const itemId = String(currentWorkItem?.id || run.currentItem || 'unknown-item');
432
+ const checkpointLabel = String(resolvedApproval.checkpoint || 'plan').replace(/[_\s]+/g, '-');
433
+
434
+ return {
435
+ flow: 'fire',
436
+ title: 'Approval Needed',
437
+ message: `${run.id}: ${itemId} (${modeLabel}) is waiting at ${checkpointLabel} checkpoint`,
438
+ checkpoint: checkpointLabel,
439
+ source: resolvedApproval.source
440
+ };
441
+ }
442
+
443
+ function isFireRunAwaitingApproval(run, currentWorkItem) {
444
+ return Boolean(getFireRunApprovalGate(run, currentWorkItem));
445
+ }
446
+
447
+ function detectFireRunApprovalGate(snapshot) {
448
+ const run = getCurrentRun(snapshot);
449
+ if (!run) {
450
+ return null;
451
+ }
452
+
453
+ const currentWorkItem = getCurrentFireWorkItem(run);
454
+ if (!currentWorkItem) {
455
+ return null;
456
+ }
457
+
458
+ return getFireRunApprovalGate(run, currentWorkItem);
459
+ }
460
+
461
+ function normalizeStageName(stage) {
462
+ return normalizeToken(stage).replace(/_/g, '-');
463
+ }
464
+
465
+ function getAidlcCheckpointSignalFiles(boltType, stageName) {
466
+ const normalizedType = normalizeToken(boltType).replace(/_/g, '-');
467
+ const normalizedStage = normalizeStageName(stageName);
468
+
469
+ if (normalizedType === 'simple-construction-bolt') {
470
+ if (normalizedStage === 'plan') return ['implementation-plan.md'];
471
+ if (normalizedStage === 'implement') return ['implementation-walkthrough.md'];
472
+ if (normalizedStage === 'test') return ['test-walkthrough.md'];
473
+ return [];
474
+ }
475
+
476
+ if (normalizedType === 'ddd-construction-bolt') {
477
+ if (normalizedStage === 'model') return ['ddd-01-domain-model.md'];
478
+ if (normalizedStage === 'design') return ['ddd-02-technical-design.md'];
479
+ if (normalizedStage === 'implement') return ['implementation-walkthrough.md'];
480
+ if (normalizedStage === 'test') return ['ddd-03-test-report.md'];
481
+ return [];
482
+ }
483
+
484
+ if (normalizedType === 'spike-bolt') {
485
+ if (normalizedStage === 'explore') return ['spike-exploration.md'];
486
+ if (normalizedStage === 'document') return ['spike-report.md'];
487
+ return [];
488
+ }
489
+
490
+ return [];
491
+ }
492
+
493
+ function hasAidlcCheckpointSignal(bolt, stageName) {
494
+ const fileNames = Array.isArray(bolt?.files) ? bolt.files : [];
495
+ const lowerNames = new Set(fileNames.map((name) => String(name || '').toLowerCase()));
496
+ const expectedFiles = getAidlcCheckpointSignalFiles(bolt?.type, stageName)
497
+ .map((name) => String(name).toLowerCase());
498
+
499
+ for (const expectedFile of expectedFiles) {
500
+ if (lowerNames.has(expectedFile)) {
501
+ return true;
502
+ }
503
+ }
504
+
505
+ if (normalizeStageName(stageName) === 'adr') {
506
+ for (const name of lowerNames) {
507
+ if (/^adr-[\w-]+\.md$/.test(name)) {
508
+ return true;
509
+ }
510
+ }
511
+ }
512
+
513
+ return false;
514
+ }
515
+
516
+ function isAidlcBoltAwaitingApproval(bolt) {
517
+ if (!bolt || normalizeToken(bolt.status) !== 'in_progress') {
518
+ return false;
519
+ }
520
+
521
+ const currentStage = normalizeStageName(bolt.currentStage);
522
+ if (!currentStage) {
523
+ return false;
524
+ }
525
+
526
+ const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
527
+ const stageMeta = stages.find((stage) => normalizeStageName(stage?.name) === currentStage);
528
+ if (normalizeToken(stageMeta?.status) === 'completed') {
529
+ return false;
530
+ }
531
+
532
+ return hasAidlcCheckpointSignal(bolt, currentStage);
533
+ }
534
+
535
+ function detectAidlcBoltApprovalGate(snapshot) {
536
+ const bolt = getCurrentBolt(snapshot);
537
+ if (!bolt) {
538
+ return null;
539
+ }
540
+
541
+ if (!isAidlcBoltAwaitingApproval(bolt)) {
542
+ return null;
543
+ }
544
+
545
+ return {
546
+ flow: 'aidlc',
547
+ title: 'Approval Needed',
548
+ message: `${bolt.id}: ${bolt.currentStage || 'current'} stage is waiting for confirmation`
549
+ };
550
+ }
551
+
552
+ function detectDashboardApprovalGate(snapshot, flow) {
553
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
554
+ if (effectiveFlow === 'fire') {
555
+ return detectFireRunApprovalGate(snapshot);
556
+ }
557
+ if (effectiveFlow === 'aidlc') {
558
+ return detectAidlcBoltApprovalGate(snapshot);
559
+ }
560
+ return null;
561
+ }
562
+
256
563
  function getCurrentPhaseLabel(run, currentWorkItem) {
257
564
  const phase = currentWorkItem?.currentPhase || '';
258
565
  if (typeof phase === 'string' && phase !== '') {
@@ -1185,10 +1492,8 @@ function buildFireCurrentRunGroups(snapshot) {
1185
1492
 
1186
1493
  const workItems = Array.isArray(run.workItems) ? run.workItems : [];
1187
1494
  const completed = workItems.filter((item) => item.status === 'completed').length;
1188
- const currentWorkItem = workItems.find((item) => item.id === run.currentItem)
1189
- || workItems.find((item) => item.status === 'in_progress')
1190
- || workItems[0]
1191
- || null;
1495
+ const currentWorkItem = getCurrentFireWorkItem(run);
1496
+ const awaitingApproval = isFireRunAwaitingApproval(run, currentWorkItem);
1192
1497
 
1193
1498
  const currentPhase = getCurrentPhaseLabel(run, currentWorkItem);
1194
1499
  const phaseTrack = buildPhaseTrack(currentPhase);
@@ -1232,7 +1537,7 @@ function buildFireCurrentRunGroups(snapshot) {
1232
1537
  return [
1233
1538
  {
1234
1539
  key: `current:run:${run.id}:summary`,
1235
- label: `${run.id} [${run.scope}] ${completed}/${workItems.length} items`,
1540
+ label: `${run.id} [${run.scope}] ${completed}/${workItems.length} items${awaitingApproval ? ' [APPROVAL]' : ''}`,
1236
1541
  files: []
1237
1542
  },
1238
1543
  {
@@ -1258,9 +1563,10 @@ function buildCurrentGroups(snapshot, flow) {
1258
1563
  }
1259
1564
  const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
1260
1565
  const completedStages = stages.filter((stage) => stage.status === 'completed').length;
1566
+ const awaitingApproval = isAidlcBoltAwaitingApproval(bolt);
1261
1567
  return [{
1262
1568
  key: `current:bolt:${bolt.id}`,
1263
- label: `${bolt.id} [${bolt.type}] ${completedStages}/${stages.length} stages`,
1569
+ label: `${bolt.id} [${bolt.type}] ${completedStages}/${stages.length} stages${awaitingApproval ? ' [APPROVAL]' : ''}`,
1264
1570
  files: filterExistingFiles([
1265
1571
  ...collectAidlcBoltFiles(bolt),
1266
1572
  ...collectAidlcIntentContextFiles(snapshot, bolt.intent)
@@ -2403,6 +2709,10 @@ function createDashboardApp(deps) {
2403
2709
  }, [showErrorPanelForSections]);
2404
2710
 
2405
2711
  const effectiveFlow = getEffectiveFlow(activeFlow, snapshot);
2712
+ const approvalGate = detectDashboardApprovalGate(snapshot, activeFlow);
2713
+ const approvalGateLine = approvalGate
2714
+ ? `[APPROVAL NEEDED] ${approvalGate.message}`
2715
+ : '';
2406
2716
  const currentGroups = buildCurrentGroups(snapshot, activeFlow);
2407
2717
  const currentExpandedGroups = { ...expandedGroups };
2408
2718
  for (const group of currentGroups) {
@@ -2411,11 +2721,24 @@ function createDashboardApp(deps) {
2411
2721
  }
2412
2722
  }
2413
2723
 
2414
- const currentRunRows = toExpandableRows(
2724
+ const currentRunRowsBase = toExpandableRows(
2415
2725
  currentGroups,
2416
2726
  getNoCurrentMessage(effectiveFlow),
2417
2727
  currentExpandedGroups
2418
2728
  );
2729
+ const currentRunRows = approvalGate
2730
+ ? [
2731
+ {
2732
+ kind: 'info',
2733
+ key: 'approval-gate',
2734
+ label: approvalGateLine,
2735
+ color: 'yellow',
2736
+ bold: true,
2737
+ selectable: false
2738
+ },
2739
+ ...currentRunRowsBase
2740
+ ]
2741
+ : currentRunRowsBase;
2419
2742
  const shouldHydrateSecondaryTabs = deferredTabsReady || ui.view !== 'runs';
2420
2743
  const runFileGroups = buildRunFileEntityGroups(snapshot, activeFlow, {
2421
2744
  includeBacklog: shouldHydrateSecondaryTabs
@@ -3027,12 +3350,14 @@ function createDashboardApp(deps) {
3027
3350
  const showErrorPanel = Boolean(error) && rows >= 18;
3028
3351
  const showGlobalErrorPanel = showErrorPanel && ui.view !== 'health' && !ui.showHelp;
3029
3352
  const showErrorInline = Boolean(error) && !showErrorPanel;
3353
+ const showApprovalBanner = approvalGateLine !== '' && !ui.showHelp;
3030
3354
  const showStatusLine = statusLine !== '';
3031
3355
  const densePanels = rows <= 28 || cols <= 120;
3032
3356
 
3033
3357
  const reservedRows =
3034
3358
  2 +
3035
3359
  (showFlowBar ? 1 : 0) +
3360
+ (showApprovalBanner ? 1 : 0) +
3036
3361
  (showFooterHelpLine ? 1 : 0) +
3037
3362
  (showGlobalErrorPanel ? 5 : 0) +
3038
3363
  (showErrorInline ? 1 : 0) +
@@ -3266,6 +3591,13 @@ function createDashboardApp(deps) {
3266
3591
  React.createElement(Text, { color: 'cyan' }, buildHeaderLine(snapshot, activeFlow, watchEnabled, watchStatus, lastRefreshAt, ui.view, fullWidth)),
3267
3592
  React.createElement(FlowBar, { activeFlow, width: fullWidth, flowIds: availableFlowIds }),
3268
3593
  React.createElement(TabsBar, { view: ui.view, width: fullWidth, icons, flow: activeFlow }),
3594
+ showApprovalBanner
3595
+ ? React.createElement(
3596
+ Text,
3597
+ { color: 'black', backgroundColor: 'yellow', bold: true },
3598
+ truncate(approvalGateLine, fullWidth)
3599
+ )
3600
+ : null,
3269
3601
  showErrorInline
3270
3602
  ? React.createElement(Text, { color: 'red' }, truncate(buildErrorLines(error, fullWidth)[0] || 'Error', fullWidth))
3271
3603
  : null,
@@ -3300,5 +3632,8 @@ module.exports = {
3300
3632
  truncate,
3301
3633
  fitLines,
3302
3634
  safeJsonHash,
3303
- allocateSingleColumnPanels
3635
+ allocateSingleColumnPanels,
3636
+ detectDashboardApprovalGate,
3637
+ detectFireRunApprovalGate,
3638
+ detectAidlcBoltApprovalGate
3304
3639
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "Multi-agent orchestration system for AI-native software development. Delivers AI-DLC, Agile, and custom SDLC flows as markdown-based agent systems.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {