@axiomatic-labs/claudeflow 2.13.22 → 2.13.24

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.
@@ -24,7 +24,10 @@ const path = require('path');
24
24
 
25
25
  const OVERRIDES_REL = path.join('.claudeflow', 'config', 'user-hook-overrides.json');
26
26
  const SETTINGS_REL = path.join('.claude', 'settings.json');
27
- const HOOK_PATH_REGEX = /\.claude\/hooks\/(.+?\.js)/;
27
+ // Capture every `.claude/hooks/<path>.js` token in a command string. Wrapped
28
+ // commands include both `run-with-override.js` (the wrapper) and the actual
29
+ // hook path; we want the LAST match — that's the real handler.
30
+ const HOOK_PATH_REGEX_GLOBAL = /\.claude\/hooks\/([^"'\s]+\.js)/g;
28
31
 
29
32
  function readJsonSafe(absPath) {
30
33
  try { return JSON.parse(fs.readFileSync(absPath, 'utf8')); } catch { return null; }
@@ -37,12 +40,18 @@ function writeJson(absPath, data) {
37
40
 
38
41
  function normalizeHandlerPath(command) {
39
42
  if (!command) return '';
40
- const m = HOOK_PATH_REGEX.exec(command);
41
- return m ? m[1] : command.replace(/^node\s+/, '').replace(/^["']|["']$/g, '').trim();
43
+ const matches = [...command.matchAll(HOOK_PATH_REGEX_GLOBAL)];
44
+ if (matches.length === 0) {
45
+ return command.replace(/^node\s+/, '').replace(/^["']|["']$/g, '').trim();
46
+ }
47
+ // Skip the wrapper itself when present; pick the last non-wrapper match.
48
+ const real = matches.filter((m) => m[1] !== 'run-with-override.js');
49
+ const chosen = real.length ? real[real.length - 1] : matches[matches.length - 1];
50
+ return chosen[1];
42
51
  }
43
52
 
44
53
  function defaultOverrides() {
45
- return { version: 1, disabledHandlers: [] };
54
+ return { version: 1, disabledHandlers: [], disabledReminders: [] };
46
55
  }
47
56
 
48
57
  function readOverrides(projectRoot) {
@@ -51,6 +60,7 @@ function readOverrides(projectRoot) {
51
60
  return {
52
61
  version: data.version || 1,
53
62
  disabledHandlers: Array.isArray(data.disabledHandlers) ? data.disabledHandlers : [],
63
+ disabledReminders: Array.isArray(data.disabledReminders) ? data.disabledReminders : [],
54
64
  };
55
65
  }
56
66
 
@@ -128,10 +138,13 @@ function restoreHandlerInSettings(settings, event, matcher, spec) {
128
138
  if (!already) rule.hooks.push(spec);
129
139
  }
130
140
 
131
- // Stateful toggle used by the panel.
132
- // - disable=true: capture spec from current settings.json, append to
133
- // overrides, remove from settings.json
134
- // - disable=false: pop spec from overrides, restore to settings.json
141
+ // Stateful toggle used by the panel. Only edits overrides.json — settings.json
142
+ // is never mutated. The wrapper (.claude/hooks/run-with-override.js) reads
143
+ // overrides on every invocation, so disable/enable takes effect on the next
144
+ // hook fire (no Claude Code restart required).
145
+ //
146
+ // Settings.json is also consulted to verify the handler exists at all
147
+ // (refuses to disable a phantom handler), but it is never written.
135
148
  function toggleHookOverride(projectRoot, { event, matcher = '', handler, disable }) {
136
149
  const settingsPath = path.join(projectRoot, SETTINGS_REL);
137
150
  const settings = readJsonSafe(settingsPath) || { hooks: {} };
@@ -143,12 +156,11 @@ function toggleHookOverride(projectRoot, { event, matcher = '', handler, disable
143
156
  (d) => d.event === event && (d.matcher || '') === matcherKey && d.handler === handler,
144
157
  );
145
158
  if (already) return { state: 'noop', reason: 'already-disabled' };
146
- const spec = findHandlerInSettings(settings, event, matcherKey, handler);
147
- if (!spec) return { state: 'error', reason: 'handler-not-found-in-settings' };
148
- overrides.disabledHandlers.push({ event, matcher: matcherKey, handler, spec });
159
+ if (!findHandlerInSettings(settings, event, matcherKey, handler)) {
160
+ return { state: 'error', reason: 'handler-not-found-in-settings' };
161
+ }
162
+ overrides.disabledHandlers.push({ event, matcher: matcherKey, handler });
149
163
  writeOverrides(projectRoot, overrides);
150
- const next = applyHookOverrides(settings, overrides);
151
- fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2) + '\n');
152
164
  return { state: 'disabled' };
153
165
  }
154
166
 
@@ -156,10 +168,27 @@ function toggleHookOverride(projectRoot, { event, matcher = '', handler, disable
156
168
  (d) => d.event === event && (d.matcher || '') === matcherKey && d.handler === handler,
157
169
  );
158
170
  if (idx === -1) return { state: 'noop', reason: 'not-disabled' };
159
- const [removed] = overrides.disabledHandlers.splice(idx, 1);
160
- if (!removed.spec) return { state: 'error', reason: 'no-spec-recorded' };
161
- restoreHandlerInSettings(settings, event, matcherKey, removed.spec);
162
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
171
+ overrides.disabledHandlers.splice(idx, 1);
172
+ writeOverrides(projectRoot, overrides);
173
+ return { state: 'enabled' };
174
+ }
175
+
176
+ // Toggle a single reminder by id. Same persistence file, same hot-reload
177
+ // semantics — `reload-reminder.js` re-reads overrides on every invocation.
178
+ function toggleReminderOverride(projectRoot, { id, disable }) {
179
+ if (typeof id !== 'string' || !id.trim()) return { state: 'error', reason: 'id-required' };
180
+ const overrides = readOverrides(projectRoot);
181
+ const list = overrides.disabledReminders;
182
+ const idx = list.indexOf(id);
183
+
184
+ if (disable) {
185
+ if (idx !== -1) return { state: 'noop', reason: 'already-disabled' };
186
+ list.push(id);
187
+ writeOverrides(projectRoot, overrides);
188
+ return { state: 'disabled' };
189
+ }
190
+ if (idx === -1) return { state: 'noop', reason: 'not-disabled' };
191
+ list.splice(idx, 1);
163
192
  writeOverrides(projectRoot, overrides);
164
193
  return { state: 'enabled' };
165
194
  }
@@ -172,6 +201,7 @@ module.exports = {
172
201
  readOverrides,
173
202
  writeOverrides,
174
203
  toggleHookOverride,
204
+ toggleReminderOverride,
175
205
  normalizeHandlerPath,
176
206
  matchesOverride,
177
207
  };
package/lib/install.js CHANGED
@@ -86,10 +86,9 @@ async function run() {
86
86
  fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
87
87
  if (fs.existsSync(srcSettings)) {
88
88
  syncClaudeSettings(srcSettings, dstSettings);
89
- // Re-apply user hook overrides (if any) so disabled handlers stay
90
- // disabled across update without this, every update would resurrect
91
- // hooks the user explicitly turned off via `claudeflow panel`.
92
- applyHookOverridesToFile(cwd);
89
+ // Settings.json now ships with every hook command wrapped through
90
+ // run-with-override.js. The wrapper consults user-hook-overrides.json
91
+ // at invocation time, so toggles persist without any settings rewrite.
93
92
  }
94
93
 
95
94
  // Upsert playwright entry in .mcp.json with project-specific CDP port.
package/lib/panel.js CHANGED
@@ -24,9 +24,26 @@ const {
24
24
  const {
25
25
  readOverrides,
26
26
  toggleHookOverride,
27
+ toggleReminderOverride,
27
28
  normalizeHandlerPath,
28
29
  } = require('./hook-overrides.js');
29
30
 
31
+ // Lazy-load the reminder catalog from the template hook helper. The path
32
+ // resolves the same way at install time and from the template repo.
33
+ function loadReminderHelpers(cwd) {
34
+ const candidates = [
35
+ path.join(cwd, '.claude', 'hooks', 'shared', 'reload-reminder.js'),
36
+ ];
37
+ for (const c of candidates) {
38
+ if (fs.existsSync(c)) {
39
+ // Bypass require cache so updates to the template are picked up.
40
+ delete require.cache[require.resolve(c)];
41
+ return require(c);
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
30
47
  const IDLE_SHUTDOWN_MS = 30 * 60 * 1000; // 30 min
31
48
 
32
49
  // ─── data collectors ─────────────────────────────────────────────
@@ -96,14 +113,18 @@ function getHooksInfo(cwd) {
96
113
  disabledIndex.set(`${d.event}|${d.matcher || ''}|${d.handler}`, d);
97
114
  }
98
115
 
116
+ // settings.json lists every hook (with each command wrapped through
117
+ // run-with-override.js). The wrapper checks overrides at invocation time,
118
+ // so the panel just annotates each handler with its current disabled
119
+ // state from the overrides file.
99
120
  const hooks = settings.hooks || {};
100
- const eventsMap = new Map();
121
+ const events = [];
101
122
  let totalHandlers = 0;
102
123
  let warnings = 0;
103
124
  let disabledCount = 0;
104
125
 
105
126
  for (const [event, rules] of Object.entries(hooks)) {
106
- if (!eventsMap.has(event)) eventsMap.set(event, []);
127
+ const handlers = [];
107
128
  for (const rule of rules) {
108
129
  const matcher = rule.matcher || '';
109
130
  for (const h of rule.hooks || []) {
@@ -111,44 +132,28 @@ function getHooksInfo(cwd) {
111
132
  const handlerPath = normalizeHandlerPath(cmd) || (cmd ? cmd : '');
112
133
  const isEmpty = !handlerPath;
113
134
  if (isEmpty) warnings++;
114
- eventsMap.get(event).push({
135
+ const key = `${event}|${matcher}|${handlerPath}`;
136
+ const isDisabled = disabledIndex.has(key);
137
+ if (isDisabled) disabledCount++;
138
+ handlers.push({
115
139
  handler: handlerPath || '(empty command)',
116
- matcher: matcher || '',
140
+ matcher,
117
141
  matcherDisplay: matcher || '(any)',
118
142
  empty: isEmpty,
119
- disabled: false,
143
+ disabled: isDisabled,
120
144
  rawCommand: cmd,
121
145
  });
122
146
  totalHandlers++;
123
147
  }
124
148
  }
149
+ events.push({ event, count: handlers.length, handlers });
125
150
  }
126
151
 
127
- // Add disabled handlers (live in overrides, not in settings.json)
128
- for (const d of overrides.disabledHandlers || []) {
129
- if (!eventsMap.has(d.event)) eventsMap.set(d.event, []);
130
- eventsMap.get(d.event).push({
131
- handler: d.handler,
132
- matcher: d.matcher || '',
133
- matcherDisplay: d.matcher || '(any)',
134
- empty: false,
135
- disabled: true,
136
- rawCommand: (d.spec && d.spec.command) || '',
137
- });
138
- disabledCount++;
139
- }
140
-
141
- const events = Array.from(eventsMap.entries()).map(([event, handlers]) => ({
142
- event,
143
- count: handlers.length,
144
- handlers,
145
- }));
146
-
147
152
  return {
148
153
  settingsFound: true,
149
154
  events,
150
- totalHandlers: totalHandlers + disabledCount,
151
- activeHandlers: totalHandlers,
155
+ totalHandlers,
156
+ activeHandlers: totalHandlers - disabledCount,
152
157
  disabledHandlers: disabledCount,
153
158
  warnings,
154
159
  };
@@ -239,6 +244,29 @@ function getActiveRunInfo(cwd) {
239
244
  };
240
245
  }
241
246
 
247
+ function getRemindersInfo(cwd) {
248
+ const helpers = loadReminderHelpers(cwd);
249
+ if (!helpers || typeof helpers.listReminderCatalog !== 'function') {
250
+ return { available: false, reminders: [], disabledCount: 0 };
251
+ }
252
+ const catalog = helpers.listReminderCatalog();
253
+ const overrides = readOverrides(cwd);
254
+ const disabledSet = new Set(overrides.disabledReminders || []);
255
+ const reminders = catalog.map((r) => ({
256
+ id: r.id,
257
+ label: r.label,
258
+ description: r.description,
259
+ slot: r.slot,
260
+ disabled: disabledSet.has(r.id),
261
+ }));
262
+ return {
263
+ available: true,
264
+ reminders,
265
+ disabledCount: reminders.filter((r) => r.disabled).length,
266
+ activeCount: reminders.filter((r) => !r.disabled).length,
267
+ };
268
+ }
269
+
242
270
  function getDoctorInfo(cwd) {
243
271
  const checks = [checkCdpPortMismatch(cwd), checkStaleLockfiles(cwd)];
244
272
  const issues = checks.filter((c) => c.severity !== 'ok' && c.severity !== 'info');
@@ -259,6 +287,7 @@ function collectStatus(cwd) {
259
287
  mcp: getMcpInfo(cwd),
260
288
  setupContext: getSetupContextInfo(cwd),
261
289
  activeRun: getActiveRunInfo(cwd),
290
+ reminders: getRemindersInfo(cwd),
262
291
  doctor: getDoctorInfo(cwd),
263
292
  };
264
293
  }
@@ -350,6 +379,12 @@ details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--bord
350
379
  .toggle input { accent-color: var(--accent); }
351
380
  .disabled-row { opacity: 0.55; }
352
381
  .disabled-row .matcher { font-style: italic; }
382
+ .reminder-row { padding: 10px 0; border-bottom: 1px solid var(--border); }
383
+ .reminder-row:last-child { border-bottom: 0; }
384
+ .reminder-row .reminder-label { font-size: 13px; }
385
+ .reminder-row .reminder-desc { font-size: 12px; margin-left: 22px; margin-top: 2px; }
386
+ .reminder-row .reminder-id { font-size: 11px; margin-left: 22px; margin-top: 2px; }
387
+ .reminder-row code { background: var(--panel-2); padding: 1px 5px; border-radius: 3px; font-size: 11px; }
353
388
  .toast { position: fixed; bottom: 18px; right: 18px; background: var(--panel); border: 1px solid var(--border); padding: 10px 14px; border-radius: 6px; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
354
389
  .toast.err { border-color: var(--err); }
355
390
  .toast.ok { border-color: var(--ok); }
@@ -375,6 +410,7 @@ const SECTIONS = [
375
410
  { id: 'overview', label: 'Overview' },
376
411
  { id: 'claudeMd', label: 'CLAUDE.md' },
377
412
  { id: 'hooks', label: 'Hooks' },
413
+ { id: 'reminders', label: 'Reminders' },
378
414
  { id: 'mcp', label: 'MCP & observer' },
379
415
  { id: 'setupContext', label: 'Setup context' },
380
416
  { id: 'activeRun', label: 'Active run' },
@@ -385,12 +421,66 @@ let state = null;
385
421
  let active = 'overview';
386
422
  let timer = null;
387
423
 
424
+ // Preserve which <details> were open across re-renders (auto-refresh would
425
+ // otherwise collapse them every 5s).
426
+ function captureOpenDetails() {
427
+ const ids = new Set();
428
+ document.querySelectorAll('details[open][data-detail-id]').forEach(d => ids.add(d.dataset.detailId));
429
+ // Also capture anonymous details by event name (hooks accordion).
430
+ document.querySelectorAll('details[open][data-event]').forEach(d => ids.add('event:' + d.dataset.event));
431
+ return ids;
432
+ }
433
+
434
+ function restoreOpenDetails(ids) {
435
+ if (!ids.size) return;
436
+ document.querySelectorAll('details[data-detail-id]').forEach(d => {
437
+ if (ids.has(d.dataset.detailId)) d.open = true;
438
+ });
439
+ document.querySelectorAll('details[data-event]').forEach(d => {
440
+ if (ids.has('event:' + d.dataset.event)) d.open = true;
441
+ });
442
+ }
443
+
444
+ // Cache fetched CLAUDE.md content so re-renders during auto-refresh don't
445
+ // drop the preview the user already loaded.
446
+ let claudeMdCache = null;
447
+
448
+ async function loadClaudeMdPreview() {
449
+ const target = document.getElementById('claude-md-preview');
450
+ if (!target) return;
451
+ if (target.dataset.loaded === '1') return;
452
+ target.dataset.loaded = '1';
453
+ target.textContent = 'Loading…';
454
+ try {
455
+ if (!claudeMdCache) {
456
+ const r = await fetch('/api/claude-md');
457
+ claudeMdCache = await r.text();
458
+ }
459
+ const pre = document.createElement('pre');
460
+ pre.textContent = claudeMdCache;
461
+ target.innerHTML = '';
462
+ target.appendChild(pre);
463
+ } catch (e) {
464
+ target.textContent = 'Failed to load: ' + e.message;
465
+ target.dataset.loaded = '';
466
+ }
467
+ }
468
+
388
469
  async function refresh() {
389
- const r = await fetch('/api/status');
390
- state = await r.json();
470
+ const open = captureOpenDetails();
471
+ try {
472
+ const r = await fetch('/api/status');
473
+ state = await r.json();
474
+ } catch (e) {
475
+ showToast && showToast('Refresh failed: ' + e.message, 'err');
476
+ return;
477
+ }
391
478
  renderHeader();
392
479
  renderNav();
393
480
  renderContent();
481
+ restoreOpenDetails(open);
482
+ // If preview was visible/open, ensure it stays loaded after re-render.
483
+ if (open.has('claude-md-preview')) loadClaudeMdPreview();
394
484
  }
395
485
 
396
486
  function renderHeader() {
@@ -404,6 +494,7 @@ function severityFor(id) {
404
494
  case 'overview': return s.doctor.issueCount > 0 ? 'warn' : 'ok';
405
495
  case 'claudeMd': return !s.claudeMd.exists ? 'err' : (s.claudeMd.optOut ? 'info' : 'ok');
406
496
  case 'hooks': return s.hooks.warnings > 0 ? 'warn' : 'ok';
497
+ case 'reminders': return s.reminders && s.reminders.disabledCount > 0 ? 'info' : 'ok';
407
498
  case 'mcp': return !s.mcp.configFound ? 'info' : (s.mcp.playwright.match && s.mcp.staleLockfiles.length === 0 ? 'ok' : 'warn');
408
499
  case 'setupContext': return !s.setupContext.exists ? 'err' : (s.setupContext.toolingComplete ? 'ok' : 'warn');
409
500
  case 'activeRun': return s.activeRun.active ? 'info' : 'info';
@@ -447,6 +538,7 @@ function renderContent() {
447
538
  overview: renderOverview,
448
539
  claudeMd: renderClaudeMd,
449
540
  hooks: renderHooks,
541
+ reminders: renderReminders,
450
542
  mcp: renderMcp,
451
543
  setupContext: renderSetup,
452
544
  activeRun: renderRun,
@@ -468,6 +560,9 @@ function renderOverview() {
468
560
  const sc = !s.setupContext.exists ? 'missing' : (s.setupContext.toolingComplete ? 'complete' : 'incomplete (' + s.setupContext.missingTooling.join(', ') + ')');
469
561
  const ar = s.activeRun.active ? s.activeRun.runId : 'none';
470
562
  const dc = s.doctor.issueCount === 0 ? '0 issues' : s.doctor.issueCount + ' issue(s) — run \`claudeflow doctor\`';
563
+ const rm = s.reminders && s.reminders.available
564
+ ? \`\${s.reminders.activeCount} active · \${s.reminders.disabledCount} disabled\`
565
+ : 'unavailable';
471
566
  return \`<h2>Overview</h2>
472
567
  <p class="sub">Snapshot at \${new Date(s.timestamp).toLocaleTimeString()}</p>
473
568
  <div class="card">
@@ -477,6 +572,7 @@ function renderOverview() {
477
572
  \${row('MCP & observer', mc, { kind: !s.mcp.configFound ? 'info' : (s.mcp.playwright.match ? 'ok' : 'warn'), text: !s.mcp.configFound ? '·' : (s.mcp.playwright.match ? '✓' : '!') })}
478
573
  \${row('Setup context', sc, { kind: s.setupContext.exists ? (s.setupContext.toolingComplete ? 'ok' : 'warn') : 'err', text: s.setupContext.exists ? (s.setupContext.toolingComplete ? '✓' : '!') : '✗' })}
479
574
  \${row('Active run', ar, { kind: 'info', text: s.activeRun.active ? '·' : '·' })}
575
+ \${row('Reminders', rm, { kind: s.reminders && s.reminders.available ? (s.reminders.disabledCount ? 'info' : 'ok') : 'err', text: s.reminders && s.reminders.disabledCount ? '·' : '✓' })}
480
576
  \${row('Doctor', dc, { kind: s.doctor.issueCount === 0 ? 'ok' : 'warn', text: s.doctor.issueCount === 0 ? '✓' : '!' })}
481
577
  </div>\`;
482
578
  }
@@ -494,12 +590,7 @@ function renderClaudeMd() {
494
590
  \${row('Default rules block', blk, { kind: c.block.present ? 'ok' : 'info', text: c.block.present ? '✓' : '·' })}
495
591
  \${row('Opt-out marker', c.optOut ? 'present (block disabled)' : 'absent', { kind: c.optOut ? 'info' : 'ok', text: c.optOut ? '·' : '✓' })}
496
592
  </div>
497
- <details><summary>Preview content</summary><div id="claude-md-preview">Loading…</div></details>
498
- <script>
499
- fetch('/api/claude-md').then(r => r.text()).then(t => {
500
- document.getElementById('claude-md-preview').innerHTML = '<pre>' + t.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])) + '</pre>';
501
- });
502
- </\${'script'}>
593
+ <details data-detail-id="claude-md-preview"><summary>Preview content</summary><div id="claude-md-preview" class="muted">Click to load…</div></details>
503
594
  \`;
504
595
  }
505
596
 
@@ -519,7 +610,7 @@ function renderHooks() {
519
610
  <span class="matcher">\${escapeHtml(hd.matcherDisplay)}</span>
520
611
  </div>\`;
521
612
  }).join('');
522
- return \`<details><summary>\${escapeHtml(ev.event)} <span class="muted">(\${ev.count})</span></summary>\${handlers}</details>\`;
613
+ return \`<details data-event="\${escapeHtml(ev.event)}"><summary>\${escapeHtml(ev.event)} <span class="muted">(\${ev.count})</span></summary>\${handlers}</details>\`;
523
614
  }).join('');
524
615
  return \`<h2>Hooks</h2>
525
616
  <p class="sub">\${h.events.length} events · \${h.activeHandlers} active\${h.disabledHandlers ? ' · <span class="muted">' + h.disabledHandlers + ' disabled</span>' : ''}\${h.warnings ? ' · <span class="warning">' + h.warnings + ' warnings</span>' : ''}</p>
@@ -527,6 +618,35 @@ function renderHooks() {
527
618
  \${items}\`;
528
619
  }
529
620
 
621
+ function renderReminders() {
622
+ const r = state.reminders;
623
+ if (!r || !r.available) return '<h2>Reminders</h2><p class="error">Reminder catalog unavailable (template helper not found).</p>';
624
+ const slotLabel = { reads: 'Reads / anchors', rules: 'Rules', 'claude-md': 'CLAUDE.md content' };
625
+ const grouped = {};
626
+ for (const item of r.reminders) {
627
+ if (!grouped[item.slot]) grouped[item.slot] = [];
628
+ grouped[item.slot].push(item);
629
+ }
630
+ const sections = Object.entries(grouped).map(([slot, items]) => {
631
+ const rows = items.map(item => {
632
+ const checked = item.disabled ? '' : 'checked';
633
+ const dataset = \`data-id="\${escapeHtml(item.id)}"\`;
634
+ const status = item.disabled ? '<span class="badge info">disabled</span>' : '';
635
+ const cls = item.disabled ? 'reminder-row disabled-row' : 'reminder-row';
636
+ return \`<div class="\${cls}">
637
+ <label class="toggle"><input type="checkbox" class="reminder-toggle" \${dataset} \${checked} />
638
+ <span class="reminder-label"><strong>\${escapeHtml(item.label)}</strong> \${status}</span></label>
639
+ <div class="reminder-desc muted">\${escapeHtml(item.description)}</div>
640
+ <div class="reminder-id muted">id: <code>\${escapeHtml(item.id)}</code></div>
641
+ </div>\`;
642
+ }).join('');
643
+ return \`<details open><summary>\${escapeHtml(slotLabel[slot] || slot)} <span class="muted">(\${items.length})</span></summary>\${rows}</details>\`;
644
+ }).join('');
645
+ return \`<h2>Reminders</h2>
646
+ <p class="sub">\${r.activeCount} active · \${r.disabledCount} disabled. Toggles are read by <code>reload-reminder.js</code> on every hook invocation — disabling takes effect on the next user prompt without reloading Claude Code.</p>
647
+ \${sections}\`;
648
+ }
649
+
530
650
  function renderMcp() {
531
651
  const m = state.mcp;
532
652
  if (!m.configFound) return '<h2>MCP & observer</h2><p class="muted">.mcp.json not found</p>';
@@ -607,17 +727,52 @@ async function toggleHook(event, matcher, handler, disable) {
607
727
  await refresh();
608
728
  }
609
729
 
730
+ async function toggleReminder(id, disable) {
731
+ try {
732
+ const r = await fetch('/api/reminders/toggle', {
733
+ method: 'POST',
734
+ headers: { 'Content-Type': 'application/json' },
735
+ body: JSON.stringify({ id, disable }),
736
+ });
737
+ const result = await r.json();
738
+ if (!r.ok || result.state === 'error') {
739
+ showToast('Toggle failed: ' + (result.reason || result.error || 'unknown'), 'err');
740
+ } else if (result.state === 'noop') {
741
+ showToast('Already in target state', 'ok');
742
+ } else {
743
+ showToast(disable ? 'Reminder silenced. Next prompt will skip it.' : 'Reminder restored. Next prompt will include it.', 'ok');
744
+ }
745
+ } catch (e) {
746
+ showToast('Network error: ' + e.message, 'err');
747
+ }
748
+ await refresh();
749
+ }
750
+
610
751
  document.addEventListener('change', (e) => {
611
752
  const t = e.target;
612
- if (t && t.classList && t.classList.contains('hook-toggle')) {
753
+ if (!t || !t.classList) return;
754
+ if (t.classList.contains('hook-toggle')) {
613
755
  const event = t.dataset.event;
614
756
  const matcher = t.dataset.matcher;
615
757
  const handler = t.dataset.handler;
616
758
  const disable = !t.checked;
617
759
  toggleHook(event, matcher, handler, disable);
760
+ } else if (t.classList.contains('reminder-toggle')) {
761
+ const id = t.dataset.id;
762
+ const disable = !t.checked;
763
+ toggleReminder(id, disable);
618
764
  }
619
765
  });
620
766
 
767
+ // Lazy-load CLAUDE.md preview when the user opens the details element.
768
+ // The "toggle" event does not bubble by default — capture phase keeps delegation working.
769
+ document.addEventListener('toggle', (e) => {
770
+ const t = e.target;
771
+ if (t && t.tagName === 'DETAILS' && t.dataset.detailId === 'claude-md-preview' && t.open) {
772
+ loadClaudeMdPreview();
773
+ }
774
+ }, true);
775
+
621
776
  document.getElementById('refresh').onclick = refresh;
622
777
  document.getElementById('auto').onchange = (e) => {
623
778
  if (timer) clearInterval(timer);
@@ -687,6 +842,20 @@ function handler(cwd) {
687
842
  return send(status, JSON.stringify(result), 'application/json');
688
843
  }
689
844
 
845
+ if (req.method === 'POST' && route === '/api/reminders/toggle') {
846
+ const raw = await readRequestBody(req);
847
+ let payload;
848
+ try { payload = JSON.parse(raw); }
849
+ catch { return send(400, JSON.stringify({ error: 'invalid json' }), 'application/json'); }
850
+ const { id, disable } = payload;
851
+ if (typeof id !== 'string' || typeof disable !== 'boolean') {
852
+ return send(400, JSON.stringify({ error: 'id, disable required' }), 'application/json');
853
+ }
854
+ const result = toggleReminderOverride(cwd, { id, disable });
855
+ const status = result.state === 'error' ? 500 : 200;
856
+ return send(status, JSON.stringify(result), 'application/json');
857
+ }
858
+
690
859
  send(404, JSON.stringify({ error: 'not found' }), 'application/json');
691
860
  } catch (err) {
692
861
  res.writeHead(500, { 'Content-Type': 'application/json' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiomatic-labs/claudeflow",
3
- "version": "2.13.22",
3
+ "version": "2.13.24",
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"