@axiomatic-labs/claudeflow 2.13.34 → 2.13.36

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.
Files changed (2) hide show
  1. package/lib/panel.js +144 -9
  2. package/package.json +1 -1
package/lib/panel.js CHANGED
@@ -286,6 +286,30 @@ function getEnforcementInfo(cwd) {
286
286
  };
287
287
  }
288
288
 
289
+ function getActivePlanInfo(cwd) {
290
+ const p = path.join(cwd, '.claudeflow', 'state', 'active-plan.json');
291
+ let raw;
292
+ try { raw = fs.readFileSync(p, 'utf8'); }
293
+ catch { return { available: false }; }
294
+ let record;
295
+ try { record = JSON.parse(raw); }
296
+ catch (err) { return { available: false, error: err.message }; }
297
+ const captured = record.captured_at ? Date.parse(record.captured_at) : null;
298
+ const ageSeconds = captured ? Math.floor((Date.now() - captured) / 1000) : null;
299
+ const content = typeof record.content === 'string' ? record.content : '';
300
+ return {
301
+ available: true,
302
+ path: record.path || null,
303
+ source: record.source || null,
304
+ sessionId: record.session_id || null,
305
+ capturedAt: record.captured_at || null,
306
+ ageSeconds,
307
+ sha256: record.sha256 || null,
308
+ contentBytes: record.contentBytes || content.length,
309
+ summaryPreview: content.slice(0, 240).replace(/\s+/g, ' ').trim(),
310
+ };
311
+ }
312
+
289
313
  function getRemindersInfo(cwd) {
290
314
  const helpers = loadReminderHelpers(cwd);
291
315
  if (!helpers || typeof helpers.listReminderCatalog !== 'function') {
@@ -385,6 +409,7 @@ function listLogEvents(cwd, { limit = 200, before = null, type = 'all' } = {}) {
385
409
  if (type !== 'all') {
386
410
  if (type === 'enforcement' && !r.type.startsWith('enforcement')) continue;
387
411
  if (type === 'reminder' && !r.type.startsWith('reminder')) continue;
412
+ if (type === 'blocked' && r.type !== 'enforcement-blocked') continue;
388
413
  }
389
414
  // Strip large `content` from list view; clients fetch it via /api/logs/:id
390
415
  // Old sidecars (pre-token-estimate) lack `contentTokens`. Backfill on
@@ -408,6 +433,8 @@ function listLogEvents(cwd, { limit = 200, before = null, type = 'all' } = {}) {
408
433
  capturedFromStdout: r.capturedFromStdout === true,
409
434
  stdoutTruncated: r.stdoutTruncated === true,
410
435
  parseError: r.parseError || null,
436
+ decision: r.decision || null,
437
+ reason: r.reason || null,
411
438
  });
412
439
  }
413
440
  return { entries, total, thresholds };
@@ -452,11 +479,16 @@ function getLogsInfo(cwd) {
452
479
  let mostRecent = null;
453
480
  let latestReminderTokens = null;
454
481
  let latestReminderStatus = null;
482
+ let recentBlockedCount = 0;
455
483
  try {
456
484
  const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
457
485
  total = files.length;
458
- // Walk from newest to find both latest event timestamp and latest reminder.
459
- for (let i = files.length - 1; i >= 0 && (mostRecent === null || latestReminderTokens === null); i--) {
486
+ // Walk from newest. Count blocks within the last 50 events for the
487
+ // sidebar severity. Latest-reminder lookup short-circuits when both
488
+ // mostRecent + latestReminderTokens are populated AND we've scanned
489
+ // enough for the block tally.
490
+ const scanCap = Math.min(50, files.length);
491
+ for (let i = files.length - 1; i >= files.length - scanCap; i--) {
460
492
  try {
461
493
  const r = JSON.parse(fs.readFileSync(path.join(dir, files[i]), 'utf8'));
462
494
  if (mostRecent === null) mostRecent = r.timestamp;
@@ -467,6 +499,7 @@ function getLogsInfo(cwd) {
467
499
  latestReminderTokens = tokens;
468
500
  latestReminderStatus = classifyTokens(tokens, thresholds);
469
501
  }
502
+ if (r.type === 'enforcement-blocked') recentBlockedCount++;
470
503
  } catch {}
471
504
  }
472
505
  } catch {}
@@ -477,6 +510,7 @@ function getLogsInfo(cwd) {
477
510
  thresholds,
478
511
  latestReminderTokens,
479
512
  latestReminderStatus,
513
+ recentBlockedCount,
480
514
  };
481
515
  }
482
516
 
@@ -502,6 +536,7 @@ function collectStatus(cwd) {
502
536
  activeRun: getActiveRunInfo(cwd),
503
537
  reminders: getRemindersInfo(cwd),
504
538
  enforcement: getEnforcementInfo(cwd),
539
+ activePlan: getActivePlanInfo(cwd),
505
540
  logs: getLogsInfo(cwd),
506
541
  doctor: getDoctorInfo(cwd),
507
542
  };
@@ -638,6 +673,10 @@ details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--bord
638
673
  .logs-status { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
639
674
  .logs-status.injected { color: #58a6ff; }
640
675
  .logs-status.silenced { color: var(--err); font-weight: 600; }
676
+ .logs-status.blocked { color: var(--err); font-weight: 700; }
677
+ .logs-table tr.blocked .logs-handler { color: var(--err); }
678
+ .logs-table tr.blocked td:first-child { border-left: 3px solid var(--err) !important; }
679
+ .logs-reason-preview { font-size: 11px; color: var(--err); margin-top: 2px; max-width: 480px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
641
680
  .token-badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 10px; font-family: var(--mono); margin-left: 6px; vertical-align: middle; }
642
681
  .token-badge.heavy { background: rgba(210,153,34,0.18); color: var(--warn); }
643
682
  .token-badge.high { background: rgba(248,81,73,0.20); color: var(--err); }
@@ -689,6 +728,7 @@ const SECTIONS = [
689
728
  { id: 'hooks', label: 'Hooks' },
690
729
  { id: 'reminders', label: 'Reminders' },
691
730
  { id: 'logs', label: 'Logs' },
731
+ { id: 'activePlan', label: 'Active plan' },
692
732
  { id: 'mcp', label: 'MCP & observer' },
693
733
  { id: 'setupContext', label: 'Setup context' },
694
734
  { id: 'activeRun', label: 'Active run' },
@@ -746,6 +786,7 @@ async function loadClaudeMdPreview() {
746
786
 
747
787
  async function refresh() {
748
788
  const open = captureOpenDetails();
789
+ const prevPlanSha = state && state.activePlan && state.activePlan.sha256;
749
790
  try {
750
791
  const r = await fetch('/api/status');
751
792
  state = await r.json();
@@ -753,12 +794,16 @@ async function refresh() {
753
794
  showToast && showToast('Refresh failed: ' + e.message, 'err');
754
795
  return;
755
796
  }
797
+ // Invalidate cached active-plan body if the underlying sha256 changed.
798
+ const newPlanSha = state && state.activePlan && state.activePlan.sha256;
799
+ if (newPlanSha !== prevPlanSha) activePlanCache = null;
756
800
  renderHeader();
757
801
  renderNav();
758
802
  renderContent();
759
803
  restoreOpenDetails(open);
760
804
  // If preview was visible/open, ensure it stays loaded after re-render.
761
805
  if (open.has('claude-md-preview')) loadClaudeMdPreview();
806
+ if (open.has('active-plan-full')) loadActivePlanPreview();
762
807
  // Refresh logs entries silently on the Logs tab so new events appear.
763
808
  if (active === 'logs') loadLogsPage();
764
809
  }
@@ -790,8 +835,10 @@ function severityFor(id) {
790
835
  case 'claudeMd': return !s.claudeMd.exists ? 'err' : (s.claudeMd.optOut ? 'info' : 'ok');
791
836
  case 'hooks': return s.hooks.warnings > 0 ? 'warn' : 'ok';
792
837
  case 'reminders': return s.reminders && s.reminders.disabledCount > 0 ? 'info' : 'ok';
838
+ case 'activePlan': return s.activePlan && s.activePlan.available ? 'info' : 'info';
793
839
  case 'logs': {
794
840
  if (!s.logs) return 'info';
841
+ if (s.logs.recentBlockedCount > 0) return 'err';
795
842
  if (s.logs.latestReminderStatus === 'high') return 'err';
796
843
  if (s.logs.latestReminderStatus === 'heavy') return 'warn';
797
844
  return s.logs.total > 0 ? 'ok' : 'info';
@@ -841,6 +888,7 @@ function renderContent() {
841
888
  hooks: renderHooks,
842
889
  reminders: renderReminders,
843
890
  logs: renderLogs,
891
+ activePlan: renderActivePlan,
844
892
  mcp: renderMcp,
845
893
  setupContext: renderSetup,
846
894
  activeRun: renderRun,
@@ -868,6 +916,9 @@ function renderOverview() {
868
916
  const ef = s.enforcement && s.enforcement.available
869
917
  ? (s.enforcement.on ? \`ON (\${s.enforcement.count} blocking hooks)\` : \`OFF — \${s.enforcement.count} blocking hooks silenced\`)
870
918
  : 'unavailable';
919
+ const activePlanRow = s.activePlan && s.activePlan.available
920
+ ? \`\${s.activePlan.path || '(inline)'} · approved \${s.activePlan.ageSeconds == null ? 'unknown' : (s.activePlan.ageSeconds < 60 ? s.activePlan.ageSeconds + 's' : Math.floor(s.activePlan.ageSeconds / 60) + 'm')} ago · \${fmtBytes(s.activePlan.contentBytes || 0)}\`
921
+ : 'none';
871
922
  const reminderTokens = s.logs && s.logs.latestReminderTokens;
872
923
  const reminderStatus = s.logs && s.logs.latestReminderStatus;
873
924
  const lg = s.logs && s.logs.available
@@ -890,6 +941,7 @@ function renderOverview() {
890
941
  \${row('Active run', ar, { kind: 'info', text: s.activeRun.active ? '·' : '·' })}
891
942
  \${row('Reminders', rm, { kind: s.reminders && s.reminders.available ? (s.reminders.disabledCount ? 'info' : 'ok') : 'err', text: s.reminders && s.reminders.disabledCount ? '·' : '✓' })}
892
943
  \${row('Enforcement', ef, { kind: s.enforcement && s.enforcement.available ? (s.enforcement.on ? 'ok' : 'err') : 'err', text: s.enforcement && s.enforcement.available ? (s.enforcement.on ? '✓' : '✗') : '·' })}
944
+ \${row('Active plan', activePlanRow, { kind: s.activePlan && s.activePlan.available ? 'info' : 'info', text: '·' })}
893
945
  \${row('Logs', lg, { kind: logsKind, text: logsKind === 'err' ? '✗' : logsKind === 'warn' ? '!' : '·' })}
894
946
  \${row('Doctor', dc, { kind: s.doctor.issueCount === 0 ? 'ok' : 'warn', text: s.doctor.issueCount === 0 ? '✓' : '!' })}
895
947
  </div>\`;
@@ -1028,6 +1080,17 @@ async function viewLogDetail(id) {
1028
1080
  : '<span class="badge warn" title="No content was captured — the hook may have crashed before writing or returned null.">⚠ no stdout</span>');
1029
1081
  html += '<h4 style="margin-top:14px">Provenance ' + provBadge + (ev.stdoutTruncated ? ' <span class="badge warn">⚠ truncated</span>' : '') + '</h4>';
1030
1082
  }
1083
+ // Enforcement-blocked detail: show decision + full reason verbatim.
1084
+ if (ev.type === 'enforcement-blocked') {
1085
+ html += '<h4 style="margin-top:14px">Decision: <span class="badge err">' + escapeHtml(ev.decision || 'block').toUpperCase() + '</span></h4>';
1086
+ if (ev.reason) {
1087
+ html += '<h4 style="margin-top:14px">Reason</h4>';
1088
+ html += '<pre style="border-color: rgba(248,81,73,0.5)">' + escapeHtml(ev.reason) + '</pre>';
1089
+ }
1090
+ if (ev.capturedStdout) {
1091
+ html += '<details style="margin-top:14px"><summary>Raw stdout (' + fmtBytes(ev.stdoutBytes || 0) + ')</summary><pre>' + escapeHtml(ev.capturedStdout) + '</pre></details>';
1092
+ }
1093
+ }
1031
1094
  if (typeof ev.contentBytes === 'number' && ev.contentBytes > 0) {
1032
1095
  const tokens = typeof ev.contentTokens === 'number' ? ev.contentTokens : Math.round(ev.contentBytes / 4);
1033
1096
  const th = (state.logs && state.logs.thresholds) || { heavy: 8000, high: 20000 };
@@ -1092,11 +1155,14 @@ function renderLogs() {
1092
1155
  const rows = logsState.entries.map((e) => {
1093
1156
  const isReminder = e.type.startsWith('reminder');
1094
1157
  const isSilenced = e.type === 'enforcement-silenced';
1158
+ const isBlocked = e.type === 'enforcement-blocked';
1095
1159
  const badge = isReminder
1096
1160
  ? '<span class="badge info" style="background:rgba(88,166,255,0.18); color:#58a6ff">REMINDER</span>'
1097
1161
  : '<span class="badge err">ENFORCEMENT</span>';
1098
1162
  const statusCell = isSilenced
1099
1163
  ? '<span class="logs-status silenced" title="Master Enforcement OFF silenced this hook — it did NOT run. Claude Code received no output for this event.">✗ SILENCED</span>'
1164
+ : isBlocked
1165
+ ? '<span class="logs-status blocked" title="Hook ran and BLOCKED this action. Claude Code rejected the tool call. Click View for the full reason.">⛔ BLOCKED</span>'
1100
1166
  : '<span class="logs-status injected" title="Hook ran and emitted additionalContext to Claude Code">✓ injected</span>';
1101
1167
  const tokenStatus = e.tokenStatus || 'none';
1102
1168
  const tokenBadge = tokenStatus === 'high'
@@ -1110,13 +1176,18 @@ function renderLogs() {
1110
1176
  const bytesStr = e.contentBytes ? fmtBytes(e.contentBytes) : '';
1111
1177
  const tokensTitle = e.contentBytes
1112
1178
  ? \`≈\${e.contentTokens} tokens (chars/4 heuristic) · \${fmtBytes(e.contentBytes)} raw · status: \${tokenStatus}\`
1113
- : 'No content (hook was silenced — never ran)';
1114
- const rowCls = \`token-\${tokenStatus}\${isSilenced ? ' silenced' : ''}\`;
1179
+ : isBlocked
1180
+ ? 'Hook BLOCKED the action. Click View for full reason.'
1181
+ : 'No content (hook was silenced — never ran)';
1182
+ const rowCls = \`token-\${tokenStatus}\${isSilenced ? ' silenced' : ''}\${isBlocked ? ' blocked' : ''}\`;
1183
+ const reasonPreview = isBlocked && e.reason
1184
+ ? \`<div class="logs-reason-preview" title="\${escapeHtml(e.reason)}">\${escapeHtml(e.reason.split('\\n')[0].slice(0, 120))}</div>\`
1185
+ : '';
1115
1186
  return \`<tr class="\${rowCls}">
1116
1187
  <td class="logs-time"><span title="\${escapeHtml(e.timestamp)}">\${escapeHtml(fmtLocalTime(e.timestamp))}</span><br><span class="muted" style="font-size:11px">\${fmtRelativeTime(e.timestamp)}</span></td>
1117
1188
  <td>\${badge}</td>
1118
1189
  <td>\${statusCell}</td>
1119
- <td class="logs-handler"><span class="muted">\${escapeHtml(e.event)}</span><br>\${escapeHtml(e.handler)}\${e.matcher ? \` <span class="muted">(\${escapeHtml(e.matcher)})</span>\` : ''}</td>
1190
+ <td class="logs-handler"><span class="muted">\${escapeHtml(e.event)}</span><br>\${escapeHtml(e.handler)}\${e.matcher ? \` <span class="muted">(\${escapeHtml(e.matcher)})</span>\` : ''}\${reasonPreview}</td>
1120
1191
  <td class="logs-size" title="\${escapeHtml(tokensTitle)}">\${tokensStr}\${tokenBadge}\${bytesStr ? \`<br><span class="muted" style="font-size:11px">\${bytesStr}</span>\` : ''}</td>
1121
1192
  <td><button class="logs-view" data-log-id="\${escapeHtml(e.id)}">View</button></td>
1122
1193
  </tr>\`;
@@ -1136,7 +1207,8 @@ function renderLogs() {
1136
1207
  <select id="logs-filter">
1137
1208
  <option value="all" \${logsState.type === 'all' ? 'selected' : ''}>All</option>
1138
1209
  <option value="reminder" \${logsState.type === 'reminder' ? 'selected' : ''}>Reminders only</option>
1139
- <option value="enforcement" \${logsState.type === 'enforcement' ? 'selected' : ''}>Enforcement-silenced only</option>
1210
+ <option value="enforcement" \${logsState.type === 'enforcement' ? 'selected' : ''}>Enforcement (silenced + blocked)</option>
1211
+ <option value="blocked" \${logsState.type === 'blocked' ? 'selected' : ''}>Blocked actions only</option>
1140
1212
  </select>
1141
1213
  </label>
1142
1214
  <button id="logs-reload">Reload</button>
@@ -1149,6 +1221,38 @@ function renderLogs() {
1149
1221
  \${logsState.allLoaded || logsState.entries.length === 0 ? '' : '<div style="margin-top:12px"><button id="logs-load-more">Load older</button></div>'}\`;
1150
1222
  }
1151
1223
 
1224
+ function renderActivePlan() {
1225
+ const p = state.activePlan;
1226
+ if (!p || !p.available) {
1227
+ return \`<h2>Active plan</h2>
1228
+ <p class="sub">No approved plan on disk. <code>.claudeflow/state/active-plan.json</code> doesn't exist.</p>
1229
+ <p class="muted" style="font-size:12px">When you approve a plan via ExitPlanMode in Claude Code, the
1230
+ <code>capture-approved-plan</code> hook will persist it here. The next time you run
1231
+ <code>claudeflow-build</code>, phase-0 will prompt to use it as the source of truth.</p>\`;
1232
+ }
1233
+ const age = p.ageSeconds == null
1234
+ ? 'unknown age'
1235
+ : p.ageSeconds < 60 ? p.ageSeconds + 's ago'
1236
+ : p.ageSeconds < 3600 ? Math.floor(p.ageSeconds / 60) + 'm ago'
1237
+ : p.ageSeconds < 86400 ? Math.floor(p.ageSeconds / 3600) + 'h ago'
1238
+ : Math.floor(p.ageSeconds / 86400) + 'd ago';
1239
+ const sha = p.sha256 ? p.sha256.slice(0, 12) + '…' : '(none)';
1240
+ const preview = escapeHtml(p.summaryPreview || '(empty)');
1241
+ return \`<h2>Active plan</h2>
1242
+ <p class="sub">Read-only view of the latest approved plan persisted by the <code>ExitPlanMode</code> hook. The terminal prompt in <code>claudeflow-build</code> phase-0 is the only place you can ACT on this — the panel is informational.</p>
1243
+ <div class="card">
1244
+ \${row('Path', p.path || '(inline plan, no file path)')}
1245
+ \${row('Source', p.source || 'unknown')}
1246
+ \${row('Approved', age + ' · ' + (p.capturedAt || 'unknown timestamp'))}
1247
+ \${row('Session id', p.sessionId || '(unknown)')}
1248
+ \${row('Sha256', sha)}
1249
+ \${row('Size', fmtBytes(p.contentBytes || 0))}
1250
+ </div>
1251
+ <p class="sub" style="margin-top:14px">Preview</p>
1252
+ <pre style="max-height:120px">\${preview}</pre>
1253
+ <details data-detail-id="active-plan-full"><summary>Full plan content</summary><div id="active-plan-body" class="muted">Click to load…</div></details>\`;
1254
+ }
1255
+
1152
1256
  function renderMcp() {
1153
1257
  const m = state.mcp;
1154
1258
  if (!m.configFound) return '<h2>MCP & observer</h2><p class="muted">.mcp.json not found</p>';
@@ -1313,13 +1417,35 @@ document.addEventListener('change', (e) => {
1313
1417
  }
1314
1418
  });
1315
1419
 
1420
+ let activePlanCache = null;
1421
+ async function loadActivePlanPreview() {
1422
+ const target = document.getElementById('active-plan-body');
1423
+ if (!target) return;
1424
+ if (target.dataset.loaded === '1') return;
1425
+ target.dataset.loaded = '1';
1426
+ target.textContent = 'Loading…';
1427
+ try {
1428
+ if (!activePlanCache) {
1429
+ const r = await fetch('/api/active-plan');
1430
+ activePlanCache = await r.text();
1431
+ }
1432
+ const pre = document.createElement('pre');
1433
+ pre.textContent = activePlanCache;
1434
+ target.innerHTML = '';
1435
+ target.appendChild(pre);
1436
+ } catch (e) {
1437
+ target.textContent = 'Failed to load: ' + e.message;
1438
+ target.dataset.loaded = '';
1439
+ }
1440
+ }
1441
+
1316
1442
  // Lazy-load CLAUDE.md preview when the user opens the details element.
1317
1443
  // The "toggle" event does not bubble by default — capture phase keeps delegation working.
1318
1444
  document.addEventListener('toggle', (e) => {
1319
1445
  const t = e.target;
1320
- if (t && t.tagName === 'DETAILS' && t.dataset.detailId === 'claude-md-preview' && t.open) {
1321
- loadClaudeMdPreview();
1322
- }
1446
+ if (!t || t.tagName !== 'DETAILS' || !t.open) return;
1447
+ if (t.dataset.detailId === 'claude-md-preview') loadClaudeMdPreview();
1448
+ else if (t.dataset.detailId === 'active-plan-full') loadActivePlanPreview();
1323
1449
  }, true);
1324
1450
 
1325
1451
  async function toggleEnforcement(disable) {
@@ -1402,6 +1528,15 @@ function handler(cwd) {
1402
1528
  if (route === '/' || route === '/index.html') return send(200, renderHtml(), 'text/html; charset=utf-8');
1403
1529
  if (route === '/api/status') return send(200, JSON.stringify(collectStatus(cwd)), 'application/json');
1404
1530
  if (route === '/api/claude-md') return send(200, readClaudeMdSafe(cwd), 'text/plain; charset=utf-8');
1531
+ if (route === '/api/active-plan') {
1532
+ const p = path.join(cwd, '.claudeflow', 'state', 'active-plan.json');
1533
+ try {
1534
+ const r = JSON.parse(fs.readFileSync(p, 'utf8'));
1535
+ return send(200, r.content || '', 'text/plain; charset=utf-8');
1536
+ } catch {
1537
+ return send(404, '', 'text/plain');
1538
+ }
1539
+ }
1405
1540
  if (route === '/api/logs') {
1406
1541
  const limit = Math.min(parseInt(url.searchParams.get('limit'), 10) || 100, 500);
1407
1542
  const before = url.searchParams.get('before') || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiomatic-labs/claudeflow",
3
- "version": "2.13.34",
3
+ "version": "2.13.36",
4
4
  "description": "Claudeflow — AI-powered development toolkit for Claude Code. Skills, agents, hooks, and quality gates that ship production apps.",
5
5
  "bin": {
6
6
  "claudeflow": "./bin/cli.js"