@axiomatic-labs/claudeflow 2.13.18 → 2.13.20

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.
package/bin/cli.js CHANGED
@@ -19,7 +19,7 @@
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
 
22
- const SUBCOMMANDS = new Set(['install', 'version', '--version', '-v', 'help', '--help', '-h', 'doctor']);
22
+ const SUBCOMMANDS = new Set(['install', 'version', '--version', '-v', 'help', '--help', '-h', 'doctor', 'panel']);
23
23
 
24
24
  function inClaudeflowProject(startDir) {
25
25
  let dir = path.resolve(startDir);
@@ -52,6 +52,11 @@ async function runSubcommand(command) {
52
52
  const code = await doctor(process.argv.slice(3));
53
53
  process.exit(code || 0);
54
54
  }
55
+ case 'panel': {
56
+ const panel = require('../lib/panel.js');
57
+ await panel(process.argv.slice(3));
58
+ return;
59
+ }
55
60
  case 'help':
56
61
  case '--help':
57
62
  case '-h':
@@ -63,6 +68,7 @@ async function runSubcommand(command) {
63
68
  console.log(' Claudeflow commands:');
64
69
  console.log(` ${ui.CYAN}install${ui.RESET} Install or update Claudeflow in the current project`);
65
70
  console.log(` ${ui.CYAN}doctor${ui.RESET} Diagnose local issues (CDP port, stale lockfiles); add --fix to repair`);
71
+ console.log(` ${ui.CYAN}panel${ui.RESET} Open the local web dashboard for hooks, CLAUDE.md, and run state`);
66
72
  console.log(` ${ui.CYAN}version${ui.RESET} Show version info`);
67
73
  console.log(` ${ui.CYAN}help${ui.RESET} Show this message`);
68
74
  console.log('');
package/lib/install.js CHANGED
@@ -1036,22 +1036,29 @@ function ensureDefaultClaudeMdRules(projectRoot) {
1036
1036
  return { action: 'opt-out' };
1037
1037
  }
1038
1038
 
1039
+ // The block always lives at the bottom. Two paths get us there:
1040
+ // - sentinels found at the bottom and content current → unchanged
1041
+ // - sentinels found anywhere else (top, middle, stale content) →
1042
+ // remove the old block and append a fresh one at the tail.
1043
+ // - sentinels absent → append for the first time.
1039
1044
  const beginIdx = existing.indexOf(CLAUDE_MD_INJECT_BEGIN);
1040
1045
  const endIdx = existing.indexOf(CLAUDE_MD_INJECT_END);
1046
+ let userContent;
1041
1047
  if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1042
1048
  const before = existing.slice(0, beginIdx);
1043
1049
  const after = existing.slice(endIdx + CLAUDE_MD_INJECT_END.length);
1044
- const next = `${before}${block}${after}`;
1045
- if (next === existing) return { action: 'unchanged' };
1046
- fs.writeFileSync(outputPath, next);
1047
- return { action: 'updated' };
1050
+ userContent = `${before.replace(/\s+$/, '')}${after.startsWith('\n') ? '' : '\n'}${after}`.replace(/^\s+/, '').replace(/\s+$/, '');
1051
+ } else {
1052
+ userContent = existing.replace(/\s+$/, '');
1048
1053
  }
1049
1054
 
1050
- // First injection into a pre-existing CLAUDE.md prepend, keep the
1051
- // user's content verbatim below.
1052
- const separator = existing.startsWith('\n') ? '\n' : '\n\n';
1053
- fs.writeFileSync(outputPath, `${block}${separator}${existing}`);
1054
- return { action: 'injected' };
1055
+ const next = userContent.length === 0
1056
+ ? `${block}\n`
1057
+ : `${userContent}\n\n${block}\n`;
1058
+
1059
+ if (next === existing) return { action: 'unchanged' };
1060
+ fs.writeFileSync(outputPath, next);
1061
+ return { action: beginIdx !== -1 ? 'updated' : 'injected' };
1055
1062
  }
1056
1063
 
1057
1064
  function isTemplateManagedAgent(agentName) {
package/lib/panel.js ADDED
@@ -0,0 +1,616 @@
1
+ // `claudeflow panel` — local web dashboard for inspecting claudeflow state.
2
+ //
3
+ // Spawns a Node http server bound to 127.0.0.1 on a free port, opens the
4
+ // system browser, and serves a single-page UI that calls JSON endpoints
5
+ // to render: version, CLAUDE.md state, hooks, MCP/observer, setup-context,
6
+ // active build run, and doctor checks.
7
+ //
8
+ // Zero runtime dependencies — Node built-ins only. The HTML/CSS/JS frontend
9
+ // lives inline as a string template.
10
+
11
+ const http = require('http');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawn } = require('child_process');
15
+ const { URL } = require('url');
16
+
17
+ const ui = require('./ui.js');
18
+ const {
19
+ deriveCdpPort,
20
+ checkCdpPortMismatch,
21
+ checkStaleLockfiles,
22
+ readPlaywrightCdpEndpoint,
23
+ } = require('./doctor.js');
24
+
25
+ const IDLE_SHUTDOWN_MS = 30 * 60 * 1000; // 30 min
26
+
27
+ // ─── data collectors ─────────────────────────────────────────────
28
+
29
+ function getVersionInfo(cwd) {
30
+ let installed = null;
31
+ for (const rel of [path.join('.claudeflow', 'version'), path.join('.claude', '.claudeflow-version')]) {
32
+ try {
33
+ installed = fs.readFileSync(path.join(cwd, rel), 'utf8').trim().replace(/^v/, '');
34
+ if (installed) break;
35
+ } catch {}
36
+ }
37
+ return { installed: installed || 'unknown' };
38
+ }
39
+
40
+ function getClaudeMdInfo(cwd) {
41
+ const p = path.join(cwd, 'CLAUDE.md');
42
+ let stat;
43
+ try { stat = fs.statSync(p); } catch { return { exists: false, path: p }; }
44
+ const content = fs.readFileSync(p, 'utf8');
45
+ const lines = content.split('\n');
46
+ const begin = '<!-- claudeflow:default-rules:start';
47
+ const end = '<!-- claudeflow:default-rules:end -->';
48
+ const beginIdx = content.indexOf(begin);
49
+ const endIdx = content.indexOf(end);
50
+ let block = null;
51
+ let position = null;
52
+ if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
53
+ const lineStart = content.slice(0, beginIdx).split('\n').length;
54
+ const lineEnd = content.slice(0, endIdx).split('\n').length;
55
+ block = { lineStart, lineEnd, bytes: endIdx + end.length - beginIdx };
56
+ const totalLines = lines.length;
57
+ if (lineStart <= 3) position = 'top';
58
+ else if (lineEnd >= totalLines - 3) position = 'bottom';
59
+ else position = 'middle';
60
+ }
61
+ const optOut = content.includes('claudeflow:default-rules:opt-out');
62
+ return {
63
+ exists: true,
64
+ path: p,
65
+ bytes: stat.size,
66
+ lines: lines.length,
67
+ block: block ? { present: true, position, ...block } : { present: false },
68
+ optOut,
69
+ };
70
+ }
71
+
72
+ function getAppendPromptInfo(cwd) {
73
+ const p = path.join(cwd, 'append-system-prompt.md');
74
+ try {
75
+ const stat = fs.statSync(p);
76
+ return { exists: true, path: p, bytes: stat.size };
77
+ } catch {
78
+ return { exists: false, path: p };
79
+ }
80
+ }
81
+
82
+ function getHooksInfo(cwd) {
83
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
84
+ let settings;
85
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); }
86
+ catch { return { settingsFound: false, events: [] }; }
87
+
88
+ const hooks = settings.hooks || {};
89
+ const events = [];
90
+ let totalHandlers = 0;
91
+ let warnings = 0;
92
+
93
+ for (const [event, rules] of Object.entries(hooks)) {
94
+ const handlers = [];
95
+ for (const rule of rules) {
96
+ const matcher = rule.matcher || '';
97
+ for (const h of rule.hooks || []) {
98
+ const cmd = h.command || '';
99
+ const handlerPath = cmd
100
+ .replace(/^node\s+/, '')
101
+ .replace(/^"?\$CLAUDE_PROJECT_DIR"?\/?\.claude\/hooks\//, '')
102
+ .replace(/^.*\/\.claude\/hooks\//, '')
103
+ .replace(/^"?\$CLAUDE_PROJECT_DIR"?\/?/, '')
104
+ .replace(/"/g, '')
105
+ .trim();
106
+ const isEmpty = !handlerPath;
107
+ if (isEmpty) warnings++;
108
+ handlers.push({
109
+ handler: handlerPath || '(empty command)',
110
+ matcher: matcher || '(any)',
111
+ empty: isEmpty,
112
+ rawCommand: cmd,
113
+ });
114
+ totalHandlers++;
115
+ }
116
+ }
117
+ events.push({ event, count: handlers.length, handlers });
118
+ }
119
+ return { settingsFound: true, events, totalHandlers, warnings };
120
+ }
121
+
122
+ function getMcpInfo(cwd) {
123
+ const mcpPath = path.join(cwd, '.mcp.json');
124
+ let cfg;
125
+ try { cfg = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); }
126
+ catch { return { configFound: false }; }
127
+
128
+ const servers = Object.keys(cfg.mcpServers || {});
129
+ const cdp = checkCdpPortMismatch(cwd);
130
+ const reading = readPlaywrightCdpEndpoint(mcpPath);
131
+ const observer = readObserverState(cwd, deriveCdpPort(cwd));
132
+ const lockfiles = checkStaleLockfiles(cwd);
133
+
134
+ return {
135
+ configFound: true,
136
+ servers,
137
+ playwright: {
138
+ configured: reading.state === 'ok' ? reading.port : null,
139
+ computed: deriveCdpPort(cwd),
140
+ match: cdp.severity === 'ok',
141
+ state: reading.state,
142
+ message: cdp.message,
143
+ },
144
+ observer,
145
+ staleLockfiles: lockfiles.severity === 'mismatch' ? lockfiles.detail : [],
146
+ };
147
+ }
148
+
149
+ function readObserverState(cwd, cdpPort) {
150
+ const lockfile = path.join(cwd, '.claudeflow', 'tmp', `.browser-cdp-${cdpPort}.pid`);
151
+ let pid = null;
152
+ try {
153
+ pid = parseInt(fs.readFileSync(lockfile, 'utf8').trim(), 10);
154
+ } catch {
155
+ return { running: false, pid: null, lockfile };
156
+ }
157
+ if (!Number.isFinite(pid) || pid <= 0) return { running: false, pid, lockfile };
158
+ let alive = false;
159
+ try { process.kill(pid, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
160
+ return { running: alive, pid, lockfile };
161
+ }
162
+
163
+ function getSetupContextInfo(cwd) {
164
+ const p = path.join(cwd, '.claudeflow', 'config', 'setup-context.json');
165
+ let ctx;
166
+ try { ctx = JSON.parse(fs.readFileSync(p, 'utf8')); }
167
+ catch { return { exists: false, path: p }; }
168
+
169
+ const canonicalToolingTypes = ['unit_test', 'integration_test', 'api_contract_test', 'e2e_test', 'security_test'];
170
+ const tooling = ctx.test_tooling || {};
171
+ const toolingComplete = canonicalToolingTypes.every((t) => {
172
+ const e = tooling[t];
173
+ return e && typeof e === 'object' && typeof e.command_pattern === 'string' && e.command_pattern.trim();
174
+ });
175
+ const missingTooling = canonicalToolingTypes.filter((t) => {
176
+ const e = tooling[t];
177
+ return !(e && typeof e === 'object' && e.command_pattern && e.command_pattern.trim());
178
+ });
179
+
180
+ return {
181
+ exists: true,
182
+ path: p,
183
+ selections: ctx.selections || null,
184
+ testTooling: tooling,
185
+ toolingComplete,
186
+ missingTooling,
187
+ };
188
+ }
189
+
190
+ function getActiveRunInfo(cwd) {
191
+ const p = path.join(cwd, '.claudeflow', 'tmp', 'workflow-state.json');
192
+ let state;
193
+ try { state = JSON.parse(fs.readFileSync(p, 'utf8')); }
194
+ catch { return { active: false }; }
195
+ const activeRunId = state.active_run;
196
+ if (!activeRunId) return { active: false };
197
+ const run = (state.runs || {})[activeRunId] || null;
198
+ return {
199
+ active: true,
200
+ runId: activeRunId,
201
+ kind: run?.kind || null,
202
+ topLevel: run?.top_level || null,
203
+ taskCount: run?.tasks ? Object.keys(run.tasks).length : 0,
204
+ };
205
+ }
206
+
207
+ function getDoctorInfo(cwd) {
208
+ const checks = [checkCdpPortMismatch(cwd), checkStaleLockfiles(cwd)];
209
+ const issues = checks.filter((c) => c.severity !== 'ok' && c.severity !== 'info');
210
+ return {
211
+ issueCount: issues.length,
212
+ checks: checks.map((c) => ({ id: c.id, severity: c.severity, message: c.message })),
213
+ };
214
+ }
215
+
216
+ function collectStatus(cwd) {
217
+ return {
218
+ cwd,
219
+ timestamp: new Date().toISOString(),
220
+ version: getVersionInfo(cwd),
221
+ claudeMd: getClaudeMdInfo(cwd),
222
+ appendPrompt: getAppendPromptInfo(cwd),
223
+ hooks: getHooksInfo(cwd),
224
+ mcp: getMcpInfo(cwd),
225
+ setupContext: getSetupContextInfo(cwd),
226
+ activeRun: getActiveRunInfo(cwd),
227
+ doctor: getDoctorInfo(cwd),
228
+ };
229
+ }
230
+
231
+ // ─── frontend (HTML + CSS + JS embedded) ──────────────────────────
232
+
233
+ function renderHtml() {
234
+ return `<!DOCTYPE html>
235
+ <html lang="en">
236
+ <head>
237
+ <meta charset="utf-8" />
238
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
239
+ <title>claudeflow panel</title>
240
+ <style>
241
+ :root {
242
+ --bg: #0e1117;
243
+ --panel: #161b22;
244
+ --panel-2: #1c232c;
245
+ --border: #2a313c;
246
+ --fg: #e6edf3;
247
+ --muted: #8b949e;
248
+ --accent: #7c3aed;
249
+ --ok: #3fb950;
250
+ --warn: #d29922;
251
+ --err: #f85149;
252
+ --info: #58a6ff;
253
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
254
+ }
255
+ * { box-sizing: border-box; }
256
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, sans-serif; }
257
+ header { display: flex; align-items: center; gap: 12px; padding: 14px 22px; border-bottom: 1px solid var(--border); background: var(--panel); position: sticky; top: 0; z-index: 10; }
258
+ header .logo { color: var(--accent); font-size: 18px; }
259
+ header .title { font-weight: 600; letter-spacing: 0.5px; }
260
+ header .version { color: var(--muted); font-family: var(--mono); font-size: 12px; }
261
+ header .cwd { color: var(--muted); font-family: var(--mono); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
262
+ header .actions { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 12px; }
263
+ header button { background: var(--panel-2); border: 1px solid var(--border); color: var(--fg); padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
264
+ header button:hover { border-color: var(--accent); }
265
+ main { display: grid; grid-template-columns: 220px 1fr; min-height: calc(100vh - 56px); }
266
+ nav { border-right: 1px solid var(--border); background: var(--panel); padding: 12px 0; }
267
+ nav a { display: flex; align-items: center; gap: 10px; padding: 10px 22px; color: var(--fg); text-decoration: none; cursor: pointer; border-left: 3px solid transparent; font-size: 13px; }
268
+ nav a:hover { background: var(--panel-2); }
269
+ nav a.active { background: var(--panel-2); border-left-color: var(--accent); }
270
+ nav .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
271
+ .dot.ok { background: var(--ok); }
272
+ .dot.warn { background: var(--warn); }
273
+ .dot.err { background: var(--err); }
274
+ .dot.info { background: var(--muted); }
275
+ section { padding: 22px 28px; max-width: 980px; }
276
+ section h2 { font-size: 16px; margin: 0 0 6px; letter-spacing: 0.3px; }
277
+ section .sub { color: var(--muted); font-size: 12px; margin-bottom: 18px; }
278
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; margin-bottom: 14px; }
279
+ .card-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); align-items: center; gap: 10px; }
280
+ .card-row:last-child { border-bottom: 0; }
281
+ .card-row .k { color: var(--muted); font-size: 13px; }
282
+ .card-row .v { font-family: var(--mono); font-size: 13px; }
283
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-family: var(--mono); }
284
+ .badge.ok { background: rgba(63, 185, 80, 0.12); color: var(--ok); }
285
+ .badge.warn { background: rgba(210, 153, 34, 0.12); color: var(--warn); }
286
+ .badge.err { background: rgba(248, 81, 73, 0.12); color: var(--err); }
287
+ .badge.info { background: rgba(139, 148, 158, 0.16); color: var(--muted); }
288
+ pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; overflow: auto; max-height: 480px; font: 12px/1.5 var(--mono); margin: 0; }
289
+ details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 8px; }
290
+ details summary { cursor: pointer; font-weight: 500; }
291
+ details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
292
+ .handler { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; font-family: var(--mono); font-size: 12px; padding: 4px 0; align-items: center; }
293
+ .matcher { color: var(--muted); font-size: 11px; }
294
+ .muted { color: var(--muted); }
295
+ .error { color: var(--err); }
296
+ .success { color: var(--ok); }
297
+ .warning { color: var(--warn); }
298
+ .toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
299
+ .toggle input { accent-color: var(--accent); }
300
+ </style>
301
+ </head>
302
+ <body>
303
+ <header>
304
+ <span class="logo">◆</span>
305
+ <span class="title">claudeflow panel</span>
306
+ <span class="version" id="version">…</span>
307
+ <span class="cwd" id="cwd">…</span>
308
+ <span class="actions">
309
+ <label class="toggle"><input type="checkbox" id="auto" checked /> auto-refresh 5s</label>
310
+ <button id="refresh">Refresh</button>
311
+ </span>
312
+ </header>
313
+ <main>
314
+ <nav id="nav"></nav>
315
+ <section id="content"><p class="muted">Loading…</p></section>
316
+ </main>
317
+ <script>
318
+ const SECTIONS = [
319
+ { id: 'overview', label: 'Overview' },
320
+ { id: 'claudeMd', label: 'CLAUDE.md' },
321
+ { id: 'hooks', label: 'Hooks' },
322
+ { id: 'mcp', label: 'MCP & observer' },
323
+ { id: 'setupContext', label: 'Setup context' },
324
+ { id: 'activeRun', label: 'Active run' },
325
+ { id: 'doctor', label: 'Issues' },
326
+ ];
327
+
328
+ let state = null;
329
+ let active = 'overview';
330
+ let timer = null;
331
+
332
+ async function refresh() {
333
+ const r = await fetch('/api/status');
334
+ state = await r.json();
335
+ renderHeader();
336
+ renderNav();
337
+ renderContent();
338
+ }
339
+
340
+ function renderHeader() {
341
+ document.getElementById('version').textContent = 'v' + state.version.installed;
342
+ document.getElementById('cwd').textContent = state.cwd;
343
+ }
344
+
345
+ function severityFor(id) {
346
+ const s = state;
347
+ switch (id) {
348
+ case 'overview': return s.doctor.issueCount > 0 ? 'warn' : 'ok';
349
+ case 'claudeMd': return !s.claudeMd.exists ? 'err' : (s.claudeMd.optOut ? 'info' : 'ok');
350
+ case 'hooks': return s.hooks.warnings > 0 ? 'warn' : 'ok';
351
+ case 'mcp': return !s.mcp.configFound ? 'info' : (s.mcp.playwright.match && s.mcp.staleLockfiles.length === 0 ? 'ok' : 'warn');
352
+ case 'setupContext': return !s.setupContext.exists ? 'err' : (s.setupContext.toolingComplete ? 'ok' : 'warn');
353
+ case 'activeRun': return s.activeRun.active ? 'info' : 'info';
354
+ case 'doctor': return s.doctor.issueCount > 0 ? 'warn' : 'ok';
355
+ }
356
+ return 'info';
357
+ }
358
+
359
+ function renderNav() {
360
+ const nav = document.getElementById('nav');
361
+ nav.innerHTML = SECTIONS.map(s => {
362
+ const sev = severityFor(s.id);
363
+ const cls = active === s.id ? 'active' : '';
364
+ return \`<a class="\${cls}" data-id="\${s.id}"><span class="dot \${sev}"></span>\${s.label}</a>\`;
365
+ }).join('');
366
+ nav.querySelectorAll('a').forEach(a => a.onclick = () => { active = a.dataset.id; renderNav(); renderContent(); });
367
+ }
368
+
369
+ function row(k, v, badge) {
370
+ const b = badge ? \`<span class="badge \${badge.kind}">\${escapeHtml(badge.text)}</span>\` : '';
371
+ return \`<div class="card-row"><span class="k">\${escapeHtml(k)}</span><span class="v">\${b}\${escapeHtml(v)}</span></div>\`;
372
+ }
373
+
374
+ function escapeHtml(s) {
375
+ return String(s == null ? '' : s)
376
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
377
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
378
+ }
379
+
380
+ function fmtBytes(n) {
381
+ if (n == null) return '—';
382
+ if (n < 1024) return n + ' B';
383
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
384
+ return (n / 1024 / 1024).toFixed(2) + ' MB';
385
+ }
386
+
387
+ function renderContent() {
388
+ const el = document.getElementById('content');
389
+ if (!state) { el.innerHTML = '<p class="muted">Loading…</p>'; return; }
390
+ const fn = {
391
+ overview: renderOverview,
392
+ claudeMd: renderClaudeMd,
393
+ hooks: renderHooks,
394
+ mcp: renderMcp,
395
+ setupContext: renderSetup,
396
+ activeRun: renderRun,
397
+ doctor: renderDoctor,
398
+ }[active];
399
+ el.innerHTML = fn ? fn() : '';
400
+ }
401
+
402
+ function renderOverview() {
403
+ const s = state;
404
+ const cm = s.claudeMd;
405
+ const cmText = cm.exists
406
+ ? \`\${fmtBytes(cm.bytes)} / \${cm.lines} lines · block: \${cm.block.present ? cm.block.position : 'absent'}\${cm.optOut ? ' · OPT-OUT' : ''}\`
407
+ : 'missing';
408
+ const ap = s.appendPrompt.exists ? fmtBytes(s.appendPrompt.bytes) : 'missing';
409
+ const hk = \`\${s.hooks.events.length} events · \${s.hooks.totalHandlers} handlers\${s.hooks.warnings ? ' · ' + s.hooks.warnings + ' warnings' : ''}\`;
410
+ const mc = !s.mcp.configFound ? '(no .mcp.json)' :
411
+ \`Playwright \${s.mcp.playwright.match ? '✓' : '✗'} \${s.mcp.playwright.configured || '?'} \${s.mcp.playwright.match ? '' : '(expected ' + s.mcp.playwright.computed + ')'} · observer \${s.mcp.observer.running ? 'running pid '+s.mcp.observer.pid : 'stopped'}\`;
412
+ const sc = !s.setupContext.exists ? 'missing' : (s.setupContext.toolingComplete ? 'complete' : 'incomplete (' + s.setupContext.missingTooling.join(', ') + ')');
413
+ const ar = s.activeRun.active ? s.activeRun.runId : 'none';
414
+ const dc = s.doctor.issueCount === 0 ? '0 issues' : s.doctor.issueCount + ' issue(s) — run \`claudeflow doctor\`';
415
+ return \`<h2>Overview</h2>
416
+ <p class="sub">Snapshot at \${new Date(s.timestamp).toLocaleTimeString()}</p>
417
+ <div class="card">
418
+ \${row('CLAUDE.md', cmText, { kind: cm.exists ? (cm.optOut ? 'info' : 'ok') : 'err', text: cm.exists ? '✓' : '✗' })}
419
+ \${row('append-system-prompt.md', ap, { kind: s.appendPrompt.exists ? 'ok' : 'err', text: s.appendPrompt.exists ? '✓' : '✗' })}
420
+ \${row('Hooks', hk, { kind: s.hooks.warnings ? 'warn' : 'ok', text: s.hooks.warnings ? '!' : '✓' })}
421
+ \${row('MCP & observer', mc, { kind: !s.mcp.configFound ? 'info' : (s.mcp.playwright.match ? 'ok' : 'warn'), text: !s.mcp.configFound ? '·' : (s.mcp.playwright.match ? '✓' : '!') })}
422
+ \${row('Setup context', sc, { kind: s.setupContext.exists ? (s.setupContext.toolingComplete ? 'ok' : 'warn') : 'err', text: s.setupContext.exists ? (s.setupContext.toolingComplete ? '✓' : '!') : '✗' })}
423
+ \${row('Active run', ar, { kind: 'info', text: s.activeRun.active ? '·' : '·' })}
424
+ \${row('Doctor', dc, { kind: s.doctor.issueCount === 0 ? 'ok' : 'warn', text: s.doctor.issueCount === 0 ? '✓' : '!' })}
425
+ </div>\`;
426
+ }
427
+
428
+ function renderClaudeMd() {
429
+ const c = state.claudeMd;
430
+ if (!c.exists) return \`<h2>CLAUDE.md</h2><p class="error">File not found at \${escapeHtml(c.path)}</p>\`;
431
+ const blk = c.block.present
432
+ ? \`present at \${c.block.position} (lines \${c.block.lineStart}–\${c.block.lineEnd}, \${fmtBytes(c.block.bytes)})\`
433
+ : 'absent (no claudeflow:default-rules markers)';
434
+ return \`<h2>CLAUDE.md</h2>
435
+ <p class="sub">\${escapeHtml(c.path)}</p>
436
+ <div class="card">
437
+ \${row('Size', fmtBytes(c.bytes) + ' / ' + c.lines + ' lines')}
438
+ \${row('Default rules block', blk, { kind: c.block.present ? 'ok' : 'info', text: c.block.present ? '✓' : '·' })}
439
+ \${row('Opt-out marker', c.optOut ? 'present (block disabled)' : 'absent', { kind: c.optOut ? 'info' : 'ok', text: c.optOut ? '·' : '✓' })}
440
+ </div>
441
+ <details><summary>Preview content</summary><div id="claude-md-preview">Loading…</div></details>
442
+ <script>
443
+ fetch('/api/claude-md').then(r => r.text()).then(t => {
444
+ document.getElementById('claude-md-preview').innerHTML = '<pre>' + t.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])) + '</pre>';
445
+ });
446
+ </\${'script'}>
447
+ \`;
448
+ }
449
+
450
+ function renderHooks() {
451
+ const h = state.hooks;
452
+ if (!h.settingsFound) return '<h2>Hooks</h2><p class="error">.claude/settings.json not found</p>';
453
+ const items = h.events.map(ev => {
454
+ const handlers = ev.handlers.map(handler => {
455
+ const mark = handler.empty ? '<span class="warning">⚠ empty command</span>' : '';
456
+ return \`<div class="handler"><span>\${escapeHtml(handler.handler)} \${mark}</span><span class="matcher">\${escapeHtml(handler.matcher)}</span></div>\`;
457
+ }).join('');
458
+ return \`<details><summary>\${escapeHtml(ev.event)} <span class="muted">(\${ev.count})</span></summary>\${handlers}</details>\`;
459
+ }).join('');
460
+ return \`<h2>Hooks</h2>
461
+ <p class="sub">\${h.events.length} events · \${h.totalHandlers} handlers\${h.warnings ? ' · <span class="warning">' + h.warnings + ' warnings</span>' : ''}</p>
462
+ \${items}\`;
463
+ }
464
+
465
+ function renderMcp() {
466
+ const m = state.mcp;
467
+ if (!m.configFound) return '<h2>MCP & observer</h2><p class="muted">.mcp.json not found</p>';
468
+ const lockfiles = m.staleLockfiles.length === 0
469
+ ? '<p class="muted">No stale lockfiles.</p>'
470
+ : '<ul>' + m.staleLockfiles.map(l => \`<li>\${escapeHtml(l.file)} (\${escapeHtml(l.reason)}\${l.pid ? ', pid='+l.pid : ''})</li>\`).join('') + '</ul>';
471
+ return \`<h2>MCP & observer</h2>
472
+ <p class="sub">Servers: \${m.servers.map(escapeHtml).join(', ') || 'none'}</p>
473
+ <div class="card">
474
+ \${row('Playwright --cdp-endpoint port', String(m.playwright.configured || 'n/a'))}
475
+ \${row('Computed port (from path)', String(m.playwright.computed))}
476
+ \${row('Match', m.playwright.match ? 'YES' : 'NO', { kind: m.playwright.match ? 'ok' : 'err', text: m.playwright.match ? '✓' : '✗' })}
477
+ \${row('Observer', m.observer.running ? 'running (pid '+m.observer.pid+')' : 'stopped', { kind: m.observer.running ? 'ok' : 'info', text: m.observer.running ? '✓' : '·' })}
478
+ </div>
479
+ <h2 style="margin-top:18px">Stale lockfiles</h2>
480
+ \${lockfiles}\`;
481
+ }
482
+
483
+ function renderSetup() {
484
+ const s = state.setupContext;
485
+ if (!s.exists) return '<h2>Setup context</h2><p class="error">setup-context.json not found</p>';
486
+ const sel = s.selections ? '<pre>' + escapeHtml(JSON.stringify(s.selections, null, 2)) + '</pre>' : '<p class="muted">No selections.</p>';
487
+ const tooling = '<pre>' + escapeHtml(JSON.stringify(s.testTooling, null, 2)) + '</pre>';
488
+ const missing = s.missingTooling.length ? '<p class="warning">Missing types: ' + s.missingTooling.map(escapeHtml).join(', ') + '</p>' : '<p class="success">All canonical tooling types defined.</p>';
489
+ return \`<h2>Setup context</h2>
490
+ <p class="sub">\${escapeHtml(s.path)}</p>
491
+ <h3>selections</h3>\${sel}
492
+ <h3>test_tooling</h3>\${missing}\${tooling}\`;
493
+ }
494
+
495
+ function renderRun() {
496
+ const r = state.activeRun;
497
+ if (!r.active) return '<h2>Active run</h2><p class="muted">No active build run.</p>';
498
+ return \`<h2>Active run</h2>
499
+ <div class="card">
500
+ \${row('Run ID', r.runId)}
501
+ \${row('Kind', r.kind || '—')}
502
+ \${row('Top-level subject', (r.topLevel && r.topLevel.subject) || '—')}
503
+ \${row('Tasks tracked', String(r.taskCount))}
504
+ </div>\`;
505
+ }
506
+
507
+ function renderDoctor() {
508
+ const d = state.doctor;
509
+ return \`<h2>Issues</h2>
510
+ <p class="sub">\${d.issueCount === 0 ? 'No issues detected.' : d.issueCount + ' issue(s) detected. Run <code>claudeflow doctor --fix</code> to repair.'}</p>
511
+ <div class="card">
512
+ \${d.checks.map(c => row(c.id, c.message, { kind: c.severity === 'ok' ? 'ok' : (c.severity === 'info' ? 'info' : 'warn'), text: c.severity })).join('')}
513
+ </div>\`;
514
+ }
515
+
516
+ document.getElementById('refresh').onclick = refresh;
517
+ document.getElementById('auto').onchange = (e) => {
518
+ if (timer) clearInterval(timer);
519
+ if (e.target.checked) timer = setInterval(refresh, 5000);
520
+ };
521
+ timer = setInterval(refresh, 5000);
522
+ refresh();
523
+ </script>
524
+ </body>
525
+ </html>`;
526
+ }
527
+
528
+ // ─── HTTP server ─────────────────────────────────────────────────
529
+
530
+ function readClaudeMdSafe(cwd) {
531
+ try { return fs.readFileSync(path.join(cwd, 'CLAUDE.md'), 'utf8'); }
532
+ catch { return ''; }
533
+ }
534
+
535
+ function handler(cwd) {
536
+ return (req, res) => {
537
+ try {
538
+ const url = new URL(req.url, 'http://localhost');
539
+ const route = url.pathname;
540
+
541
+ const send = (status, body, type) => {
542
+ res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
543
+ res.end(body);
544
+ };
545
+
546
+ if (route === '/' || route === '/index.html') return send(200, renderHtml(), 'text/html; charset=utf-8');
547
+ if (route === '/api/status') return send(200, JSON.stringify(collectStatus(cwd)), 'application/json');
548
+ if (route === '/api/claude-md') return send(200, readClaudeMdSafe(cwd), 'text/plain; charset=utf-8');
549
+ if (route === '/healthz') return send(200, 'ok', 'text/plain');
550
+ send(404, JSON.stringify({ error: 'not found' }), 'application/json');
551
+ } catch (err) {
552
+ res.writeHead(500, { 'Content-Type': 'application/json' });
553
+ res.end(JSON.stringify({ error: err.message }));
554
+ }
555
+ };
556
+ }
557
+
558
+ function openInBrowser(url) {
559
+ const cmd = process.platform === 'darwin' ? 'open'
560
+ : process.platform === 'win32' ? 'start'
561
+ : 'xdg-open';
562
+ try {
563
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
564
+ } catch {}
565
+ }
566
+
567
+ function start({ cwd = process.cwd(), port = 0, openBrowser = true } = {}) {
568
+ const server = http.createServer(handler(cwd));
569
+ let lastActivity = Date.now();
570
+ server.on('request', () => { lastActivity = Date.now(); });
571
+
572
+ return new Promise((resolve, reject) => {
573
+ server.once('error', reject);
574
+ server.listen(port, '127.0.0.1', () => {
575
+ const addr = server.address();
576
+ const url = `http://127.0.0.1:${addr.port}`;
577
+ const idleTimer = setInterval(() => {
578
+ if (Date.now() - lastActivity > IDLE_SHUTDOWN_MS) {
579
+ clearInterval(idleTimer);
580
+ server.close();
581
+ process.exit(0);
582
+ }
583
+ }, 60 * 1000);
584
+ idleTimer.unref();
585
+ if (openBrowser) openInBrowser(url);
586
+ resolve({ url, server });
587
+ });
588
+ });
589
+ }
590
+
591
+ async function run(argv = []) {
592
+ const cwd = process.cwd();
593
+ const noOpen = argv.includes('--no-open');
594
+ const portArg = argv.find((a) => a.startsWith('--port='));
595
+ const port = portArg ? parseInt(portArg.split('=')[1], 10) : 0;
596
+
597
+ ui.banner();
598
+ console.log(` Starting panel for ${ui.CYAN}${cwd}${ui.RESET}`);
599
+ const { url } = await start({ cwd, port, openBrowser: !noOpen });
600
+ console.log('');
601
+ console.log(` ${ui.GREEN}▸${ui.RESET} ${ui.CYAN}${url}${ui.RESET}`);
602
+ console.log(` ${ui.DIM}Ctrl+C to stop. Auto-shutdown after 30 min idle.${ui.RESET}`);
603
+ console.log('');
604
+ return new Promise(() => {}); // keep alive
605
+ }
606
+
607
+ module.exports = run;
608
+ module.exports.start = start;
609
+ module.exports.collectStatus = collectStatus;
610
+ module.exports.getClaudeMdInfo = getClaudeMdInfo;
611
+ module.exports.getHooksInfo = getHooksInfo;
612
+ module.exports.getMcpInfo = getMcpInfo;
613
+ module.exports.getSetupContextInfo = getSetupContextInfo;
614
+ module.exports.getActiveRunInfo = getActiveRunInfo;
615
+ module.exports.getDoctorInfo = getDoctorInfo;
616
+ module.exports.renderHtml = renderHtml;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiomatic-labs/claudeflow",
3
- "version": "2.13.18",
3
+ "version": "2.13.20",
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"