specsmd 0.1.43 → 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)
@@ -2218,28 +2458,58 @@ function createDashboardApp(deps) {
2218
2458
  const primaryLabel = effectiveFlow === 'aidlc' ? 'BOLTS' : (effectiveFlow === 'simple' ? 'SPECS' : 'RUNS');
2219
2459
  const completedLabel = effectiveFlow === 'aidlc' ? 'COMPLETED BOLTS' : (effectiveFlow === 'simple' ? 'COMPLETED SPECS' : 'COMPLETED RUNS');
2220
2460
  const tabs = [
2221
- { id: 'runs', label: ` 1 ${icons.runs} ${primaryLabel} ` },
2222
- { id: 'intents', label: ` 2 ${icons.overview} INTENTS ` },
2223
- { id: 'completed', label: ` 3 ${icons.runs} ${completedLabel} ` },
2224
- { id: 'health', label: ` 4 ${icons.health} STANDARDS/HEALTH ` }
2461
+ { id: 'runs', label: `1 ${icons.runs} ${primaryLabel}` },
2462
+ { id: 'intents', label: `2 ${icons.overview} INTENTS` },
2463
+ { id: 'completed', label: `3 ${icons.runs} ${completedLabel}` },
2464
+ { id: 'health', label: `4 ${icons.health} STANDARDS/HEALTH` }
2225
2465
  ];
2466
+ const maxWidth = Math.max(8, Math.floor(width));
2467
+ const segments = [];
2468
+ let consumed = 0;
2469
+
2470
+ for (const tab of tabs) {
2471
+ const isActive = tab.id === view;
2472
+ const segmentText = isActive ? `[${tab.label}]` : tab.label;
2473
+ const separator = segments.length > 0 ? ' ' : '';
2474
+ const segmentWidth = stringWidth(separator) + stringWidth(segmentText);
2475
+ if (consumed + segmentWidth > maxWidth) {
2476
+ break;
2477
+ }
2478
+
2479
+ if (separator !== '') {
2480
+ segments.push({
2481
+ key: `${tab.id}:sep`,
2482
+ text: separator,
2483
+ active: false
2484
+ });
2485
+ }
2486
+ segments.push({
2487
+ key: tab.id,
2488
+ text: segmentText,
2489
+ active: isActive
2490
+ });
2491
+ consumed += segmentWidth;
2492
+ }
2493
+
2494
+ if (segments.length === 0) {
2495
+ const fallback = tabs.find((tab) => tab.id === view) || tabs[0];
2496
+ const fallbackText = truncate(`[${fallback.label}]`, maxWidth);
2497
+ return React.createElement(Text, { color: 'white', bold: true }, fallbackText);
2498
+ }
2226
2499
 
2227
2500
  return React.createElement(
2228
2501
  Box,
2229
- { width, flexWrap: 'nowrap' },
2230
- ...tabs.map((tab) => {
2231
- const isActive = tab.id === view;
2232
- return React.createElement(
2233
- Text,
2234
- {
2235
- key: tab.id,
2236
- bold: isActive,
2237
- color: isActive ? 'white' : 'gray',
2238
- backgroundColor: isActive ? 'blue' : undefined
2239
- },
2240
- tab.label
2241
- );
2242
- })
2502
+ { width: maxWidth, flexWrap: 'nowrap' },
2503
+ ...segments.map((segment) => React.createElement(
2504
+ Text,
2505
+ {
2506
+ key: segment.key,
2507
+ bold: segment.active,
2508
+ color: segment.active ? 'white' : 'gray',
2509
+ backgroundColor: segment.active ? 'blue' : undefined
2510
+ },
2511
+ segment.text
2512
+ ))
2243
2513
  );
2244
2514
  }
2245
2515
 
@@ -2248,23 +2518,52 @@ function createDashboardApp(deps) {
2248
2518
  if (!Array.isArray(flowIds) || flowIds.length <= 1) {
2249
2519
  return null;
2250
2520
  }
2521
+ const maxWidth = Math.max(8, Math.floor(width));
2522
+ const segments = [];
2523
+ let consumed = 0;
2524
+
2525
+ for (const flowId of flowIds) {
2526
+ const isActive = flowId === activeFlow;
2527
+ const segmentText = isActive ? `[${flowId.toUpperCase()}]` : flowId.toUpperCase();
2528
+ const separator = segments.length > 0 ? ' ' : '';
2529
+ const segmentWidth = stringWidth(separator) + stringWidth(segmentText);
2530
+ if (consumed + segmentWidth > maxWidth) {
2531
+ break;
2532
+ }
2533
+
2534
+ if (separator !== '') {
2535
+ segments.push({
2536
+ key: `${flowId}:sep`,
2537
+ text: separator,
2538
+ active: false
2539
+ });
2540
+ }
2541
+ segments.push({
2542
+ key: flowId,
2543
+ text: segmentText,
2544
+ active: isActive
2545
+ });
2546
+ consumed += segmentWidth;
2547
+ }
2548
+
2549
+ if (segments.length === 0) {
2550
+ const fallback = (activeFlow || flowIds[0] || 'flow').toUpperCase();
2551
+ return React.createElement(Text, { color: 'black', backgroundColor: 'green', bold: true }, truncate(`[${fallback}]`, maxWidth));
2552
+ }
2251
2553
 
2252
2554
  return React.createElement(
2253
2555
  Box,
2254
- { width, flexWrap: 'nowrap' },
2255
- ...flowIds.map((flowId) => {
2256
- const isActive = flowId === activeFlow;
2257
- return React.createElement(
2258
- Text,
2259
- {
2260
- key: flowId,
2261
- bold: isActive,
2262
- color: isActive ? 'black' : 'gray',
2263
- backgroundColor: isActive ? 'green' : undefined
2264
- },
2265
- ` ${flowId.toUpperCase()} `
2266
- );
2267
- })
2556
+ { width: maxWidth, flexWrap: 'nowrap' },
2557
+ ...segments.map((segment) => React.createElement(
2558
+ Text,
2559
+ {
2560
+ key: segment.key,
2561
+ bold: segment.active,
2562
+ color: segment.active ? 'black' : 'gray',
2563
+ backgroundColor: segment.active ? 'green' : undefined
2564
+ },
2565
+ segment.text
2566
+ ))
2268
2567
  );
2269
2568
  }
2270
2569
 
@@ -2344,6 +2643,10 @@ function createDashboardApp(deps) {
2344
2643
  }, [showErrorPanelForSections]);
2345
2644
 
2346
2645
  const effectiveFlow = getEffectiveFlow(activeFlow, snapshot);
2646
+ const approvalGate = detectDashboardApprovalGate(snapshot, activeFlow);
2647
+ const approvalGateLine = approvalGate
2648
+ ? `[APPROVAL NEEDED] ${approvalGate.message}`
2649
+ : '';
2347
2650
  const currentGroups = buildCurrentGroups(snapshot, activeFlow);
2348
2651
  const currentExpandedGroups = { ...expandedGroups };
2349
2652
  for (const group of currentGroups) {
@@ -2352,11 +2655,24 @@ function createDashboardApp(deps) {
2352
2655
  }
2353
2656
  }
2354
2657
 
2355
- const currentRunRows = toExpandableRows(
2658
+ const currentRunRowsBase = toExpandableRows(
2356
2659
  currentGroups,
2357
2660
  getNoCurrentMessage(effectiveFlow),
2358
2661
  currentExpandedGroups
2359
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;
2360
2676
  const shouldHydrateSecondaryTabs = deferredTabsReady || ui.view !== 'runs';
2361
2677
  const runFileGroups = buildRunFileEntityGroups(snapshot, activeFlow, {
2362
2678
  includeBacklog: shouldHydrateSecondaryTabs
@@ -2888,6 +3204,12 @@ function createDashboardApp(deps) {
2888
3204
  columns: stdout.columns || process.stdout.columns || 120,
2889
3205
  rows: stdout.rows || process.stdout.rows || 40
2890
3206
  });
3207
+
3208
+ // Resize in some terminals can leave stale frame rows behind.
3209
+ // Force clear so next render paints from a clean origin.
3210
+ if (typeof stdout.write === 'function' && stdout.isTTY !== false) {
3211
+ stdout.write('\u001B[2J\u001B[3J\u001B[H');
3212
+ }
2891
3213
  };
2892
3214
 
2893
3215
  updateSize();
@@ -2962,12 +3284,14 @@ function createDashboardApp(deps) {
2962
3284
  const showErrorPanel = Boolean(error) && rows >= 18;
2963
3285
  const showGlobalErrorPanel = showErrorPanel && ui.view !== 'health' && !ui.showHelp;
2964
3286
  const showErrorInline = Boolean(error) && !showErrorPanel;
3287
+ const showApprovalBanner = approvalGateLine !== '' && !ui.showHelp;
2965
3288
  const showStatusLine = statusLine !== '';
2966
3289
  const densePanels = rows <= 28 || cols <= 120;
2967
3290
 
2968
3291
  const reservedRows =
2969
3292
  2 +
2970
3293
  (showFlowBar ? 1 : 0) +
3294
+ (showApprovalBanner ? 1 : 0) +
2971
3295
  (showFooterHelpLine ? 1 : 0) +
2972
3296
  (showGlobalErrorPanel ? 5 : 0) +
2973
3297
  (showErrorInline ? 1 : 0) +
@@ -3201,6 +3525,13 @@ function createDashboardApp(deps) {
3201
3525
  React.createElement(Text, { color: 'cyan' }, buildHeaderLine(snapshot, activeFlow, watchEnabled, watchStatus, lastRefreshAt, ui.view, fullWidth)),
3202
3526
  React.createElement(FlowBar, { activeFlow, width: fullWidth, flowIds: availableFlowIds }),
3203
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,
3204
3535
  showErrorInline
3205
3536
  ? React.createElement(Text, { color: 'red' }, truncate(buildErrorLines(error, fullWidth)[0] || 'Error', fullWidth))
3206
3537
  : null,
@@ -3235,5 +3566,8 @@ module.exports = {
3235
3566
  truncate,
3236
3567
  fitLines,
3237
3568
  safeJsonHash,
3238
- allocateSingleColumnPanels
3569
+ allocateSingleColumnPanels,
3570
+ detectDashboardApprovalGate,
3571
+ detectFireRunApprovalGate,
3572
+ detectAidlcBoltApprovalGate
3239
3573
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.43",
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": {