specsmd 0.1.44 → 0.1.45

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.
@@ -253,6 +253,247 @@ 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
+ function parseFirePlanApprovalState(run) {
314
+ if (!run || typeof run.folderPath !== 'string' || run.folderPath.trim() === '') {
315
+ return { hasPlan: false, approved: false, checkpoint: null };
316
+ }
317
+
318
+ const planPath = path.join(run.folderPath, 'plan.md');
319
+ if (!fileExists(planPath)) {
320
+ return { hasPlan: false, approved: false, checkpoint: null };
321
+ }
322
+
323
+ const content = readFileTextSafe(planPath);
324
+ const frontmatter = extractFrontmatterBlock(content);
325
+ if (!frontmatter) {
326
+ return { hasPlan: true, approved: false, checkpoint: null };
327
+ }
328
+
329
+ const approvedAt = extractFrontmatterValue(frontmatter, 'approved_at');
330
+ const checkpoint = extractFrontmatterValue(frontmatter, 'checkpoint');
331
+ const missingTokens = new Set([
332
+ '',
333
+ 'null',
334
+ 'none',
335
+ 'pending',
336
+ 'unknown',
337
+ 'n/a',
338
+ 'false',
339
+ 'no',
340
+ 'assumed-from-user-n'
341
+ ]);
342
+ const normalizedApprovedAt = normalizeToken(approvedAt || '');
343
+
344
+ return {
345
+ hasPlan: true,
346
+ approved: !missingTokens.has(normalizedApprovedAt),
347
+ checkpoint: checkpoint || null
348
+ };
349
+ }
350
+
351
+ function isFireRunAwaitingApproval(run, currentWorkItem) {
352
+ const mode = normalizeToken(currentWorkItem?.mode);
353
+ const status = normalizeToken(currentWorkItem?.status);
354
+ if (!['confirm', 'validate'].includes(mode) || status !== 'in_progress') {
355
+ return false;
356
+ }
357
+
358
+ const phase = normalizeToken(getCurrentPhaseLabel(run, currentWorkItem));
359
+ if (phase !== 'plan') {
360
+ return false;
361
+ }
362
+
363
+ const planState = parseFirePlanApprovalState(run);
364
+ if (!planState.hasPlan) {
365
+ return false;
366
+ }
367
+
368
+ return !planState.approved;
369
+ }
370
+
371
+ function detectFireRunApprovalGate(snapshot) {
372
+ const run = getCurrentRun(snapshot);
373
+ if (!run) {
374
+ return null;
375
+ }
376
+
377
+ const currentWorkItem = getCurrentFireWorkItem(run);
378
+ if (!currentWorkItem) {
379
+ return null;
380
+ }
381
+
382
+ if (!isFireRunAwaitingApproval(run, currentWorkItem)) {
383
+ return null;
384
+ }
385
+
386
+ const mode = String(currentWorkItem?.mode || 'confirm').toUpperCase();
387
+ const itemId = String(currentWorkItem?.id || run.currentItem || 'unknown-item');
388
+ return {
389
+ flow: 'fire',
390
+ title: 'Approval Needed',
391
+ message: `${run.id}: ${itemId} (${mode}) is waiting at plan checkpoint`
392
+ };
393
+ }
394
+
395
+ function normalizeStageName(stage) {
396
+ return normalizeToken(stage).replace(/_/g, '-');
397
+ }
398
+
399
+ function getAidlcCheckpointSignalFiles(boltType, stageName) {
400
+ const normalizedType = normalizeToken(boltType).replace(/_/g, '-');
401
+ const normalizedStage = normalizeStageName(stageName);
402
+
403
+ if (normalizedType === 'simple-construction-bolt') {
404
+ if (normalizedStage === 'plan') return ['implementation-plan.md'];
405
+ if (normalizedStage === 'implement') return ['implementation-walkthrough.md'];
406
+ if (normalizedStage === 'test') return ['test-walkthrough.md'];
407
+ return [];
408
+ }
409
+
410
+ if (normalizedType === 'ddd-construction-bolt') {
411
+ if (normalizedStage === 'model') return ['ddd-01-domain-model.md'];
412
+ if (normalizedStage === 'design') return ['ddd-02-technical-design.md'];
413
+ if (normalizedStage === 'implement') return ['implementation-walkthrough.md'];
414
+ if (normalizedStage === 'test') return ['ddd-03-test-report.md'];
415
+ return [];
416
+ }
417
+
418
+ if (normalizedType === 'spike-bolt') {
419
+ if (normalizedStage === 'explore') return ['spike-exploration.md'];
420
+ if (normalizedStage === 'document') return ['spike-report.md'];
421
+ return [];
422
+ }
423
+
424
+ return [];
425
+ }
426
+
427
+ function hasAidlcCheckpointSignal(bolt, stageName) {
428
+ const fileNames = Array.isArray(bolt?.files) ? bolt.files : [];
429
+ const lowerNames = new Set(fileNames.map((name) => String(name || '').toLowerCase()));
430
+ const expectedFiles = getAidlcCheckpointSignalFiles(bolt?.type, stageName)
431
+ .map((name) => String(name).toLowerCase());
432
+
433
+ for (const expectedFile of expectedFiles) {
434
+ if (lowerNames.has(expectedFile)) {
435
+ return true;
436
+ }
437
+ }
438
+
439
+ if (normalizeStageName(stageName) === 'adr') {
440
+ for (const name of lowerNames) {
441
+ if (/^adr-[\w-]+\.md$/.test(name)) {
442
+ return true;
443
+ }
444
+ }
445
+ }
446
+
447
+ return false;
448
+ }
449
+
450
+ function isAidlcBoltAwaitingApproval(bolt) {
451
+ if (!bolt || normalizeToken(bolt.status) !== 'in_progress') {
452
+ return false;
453
+ }
454
+
455
+ const currentStage = normalizeStageName(bolt.currentStage);
456
+ if (!currentStage) {
457
+ return false;
458
+ }
459
+
460
+ const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
461
+ const stageMeta = stages.find((stage) => normalizeStageName(stage?.name) === currentStage);
462
+ if (normalizeToken(stageMeta?.status) === 'completed') {
463
+ return false;
464
+ }
465
+
466
+ return hasAidlcCheckpointSignal(bolt, currentStage);
467
+ }
468
+
469
+ function detectAidlcBoltApprovalGate(snapshot) {
470
+ const bolt = getCurrentBolt(snapshot);
471
+ if (!bolt) {
472
+ return null;
473
+ }
474
+
475
+ if (!isAidlcBoltAwaitingApproval(bolt)) {
476
+ return null;
477
+ }
478
+
479
+ return {
480
+ flow: 'aidlc',
481
+ title: 'Approval Needed',
482
+ message: `${bolt.id}: ${bolt.currentStage || 'current'} stage is waiting for confirmation`
483
+ };
484
+ }
485
+
486
+ function detectDashboardApprovalGate(snapshot, flow) {
487
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
488
+ if (effectiveFlow === 'fire') {
489
+ return detectFireRunApprovalGate(snapshot);
490
+ }
491
+ if (effectiveFlow === 'aidlc') {
492
+ return detectAidlcBoltApprovalGate(snapshot);
493
+ }
494
+ return null;
495
+ }
496
+
256
497
  function getCurrentPhaseLabel(run, currentWorkItem) {
257
498
  const phase = currentWorkItem?.currentPhase || '';
258
499
  if (typeof phase === 'string' && phase !== '') {
@@ -1185,10 +1426,8 @@ function buildFireCurrentRunGroups(snapshot) {
1185
1426
 
1186
1427
  const workItems = Array.isArray(run.workItems) ? run.workItems : [];
1187
1428
  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;
1429
+ const currentWorkItem = getCurrentFireWorkItem(run);
1430
+ const awaitingApproval = isFireRunAwaitingApproval(run, currentWorkItem);
1192
1431
 
1193
1432
  const currentPhase = getCurrentPhaseLabel(run, currentWorkItem);
1194
1433
  const phaseTrack = buildPhaseTrack(currentPhase);
@@ -1232,7 +1471,7 @@ function buildFireCurrentRunGroups(snapshot) {
1232
1471
  return [
1233
1472
  {
1234
1473
  key: `current:run:${run.id}:summary`,
1235
- label: `${run.id} [${run.scope}] ${completed}/${workItems.length} items`,
1474
+ label: `${run.id} [${run.scope}] ${completed}/${workItems.length} items${awaitingApproval ? ' [APPROVAL]' : ''}`,
1236
1475
  files: []
1237
1476
  },
1238
1477
  {
@@ -1258,9 +1497,10 @@ function buildCurrentGroups(snapshot, flow) {
1258
1497
  }
1259
1498
  const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
1260
1499
  const completedStages = stages.filter((stage) => stage.status === 'completed').length;
1500
+ const awaitingApproval = isAidlcBoltAwaitingApproval(bolt);
1261
1501
  return [{
1262
1502
  key: `current:bolt:${bolt.id}`,
1263
- label: `${bolt.id} [${bolt.type}] ${completedStages}/${stages.length} stages`,
1503
+ label: `${bolt.id} [${bolt.type}] ${completedStages}/${stages.length} stages${awaitingApproval ? ' [APPROVAL]' : ''}`,
1264
1504
  files: filterExistingFiles([
1265
1505
  ...collectAidlcBoltFiles(bolt),
1266
1506
  ...collectAidlcIntentContextFiles(snapshot, bolt.intent)
@@ -2403,6 +2643,10 @@ function createDashboardApp(deps) {
2403
2643
  }, [showErrorPanelForSections]);
2404
2644
 
2405
2645
  const effectiveFlow = getEffectiveFlow(activeFlow, snapshot);
2646
+ const approvalGate = detectDashboardApprovalGate(snapshot, activeFlow);
2647
+ const approvalGateLine = approvalGate
2648
+ ? `[APPROVAL NEEDED] ${approvalGate.message}`
2649
+ : '';
2406
2650
  const currentGroups = buildCurrentGroups(snapshot, activeFlow);
2407
2651
  const currentExpandedGroups = { ...expandedGroups };
2408
2652
  for (const group of currentGroups) {
@@ -2411,11 +2655,24 @@ function createDashboardApp(deps) {
2411
2655
  }
2412
2656
  }
2413
2657
 
2414
- const currentRunRows = toExpandableRows(
2658
+ const currentRunRowsBase = toExpandableRows(
2415
2659
  currentGroups,
2416
2660
  getNoCurrentMessage(effectiveFlow),
2417
2661
  currentExpandedGroups
2418
2662
  );
2663
+ const currentRunRows = approvalGate
2664
+ ? [
2665
+ {
2666
+ kind: 'info',
2667
+ key: 'approval-gate',
2668
+ label: approvalGateLine,
2669
+ color: 'yellow',
2670
+ bold: true,
2671
+ selectable: false
2672
+ },
2673
+ ...currentRunRowsBase
2674
+ ]
2675
+ : currentRunRowsBase;
2419
2676
  const shouldHydrateSecondaryTabs = deferredTabsReady || ui.view !== 'runs';
2420
2677
  const runFileGroups = buildRunFileEntityGroups(snapshot, activeFlow, {
2421
2678
  includeBacklog: shouldHydrateSecondaryTabs
@@ -3027,12 +3284,14 @@ function createDashboardApp(deps) {
3027
3284
  const showErrorPanel = Boolean(error) && rows >= 18;
3028
3285
  const showGlobalErrorPanel = showErrorPanel && ui.view !== 'health' && !ui.showHelp;
3029
3286
  const showErrorInline = Boolean(error) && !showErrorPanel;
3287
+ const showApprovalBanner = approvalGateLine !== '' && !ui.showHelp;
3030
3288
  const showStatusLine = statusLine !== '';
3031
3289
  const densePanels = rows <= 28 || cols <= 120;
3032
3290
 
3033
3291
  const reservedRows =
3034
3292
  2 +
3035
3293
  (showFlowBar ? 1 : 0) +
3294
+ (showApprovalBanner ? 1 : 0) +
3036
3295
  (showFooterHelpLine ? 1 : 0) +
3037
3296
  (showGlobalErrorPanel ? 5 : 0) +
3038
3297
  (showErrorInline ? 1 : 0) +
@@ -3266,6 +3525,13 @@ function createDashboardApp(deps) {
3266
3525
  React.createElement(Text, { color: 'cyan' }, buildHeaderLine(snapshot, activeFlow, watchEnabled, watchStatus, lastRefreshAt, ui.view, fullWidth)),
3267
3526
  React.createElement(FlowBar, { activeFlow, width: fullWidth, flowIds: availableFlowIds }),
3268
3527
  React.createElement(TabsBar, { view: ui.view, width: fullWidth, icons, flow: activeFlow }),
3528
+ showApprovalBanner
3529
+ ? React.createElement(
3530
+ Text,
3531
+ { color: 'black', backgroundColor: 'yellow', bold: true },
3532
+ truncate(approvalGateLine, fullWidth)
3533
+ )
3534
+ : null,
3269
3535
  showErrorInline
3270
3536
  ? React.createElement(Text, { color: 'red' }, truncate(buildErrorLines(error, fullWidth)[0] || 'Error', fullWidth))
3271
3537
  : null,
@@ -3300,5 +3566,8 @@ module.exports = {
3300
3566
  truncate,
3301
3567
  fitLines,
3302
3568
  safeJsonHash,
3303
- allocateSingleColumnPanels
3569
+ allocateSingleColumnPanels,
3570
+ detectDashboardApprovalGate,
3571
+ detectFireRunApprovalGate,
3572
+ detectAidlcBoltApprovalGate
3304
3573
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
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": {