@claude-flow/cli 3.25.6 → 3.26.1

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 (104) hide show
  1. package/.claude/.proven-config-version +1 -0
  2. package/.claude/helpers/.helpers-version +1 -1
  3. package/.claude/helpers/helpers.manifest.json +5 -4
  4. package/.claude/helpers/hook-handler.cjs +129 -0
  5. package/.claude/helpers/statusline.cjs +781 -505
  6. package/.claude/proven-config.json +42 -0
  7. package/catalog-manifest.json +12 -0
  8. package/dist/src/commands/advisor.d.ts +15 -0
  9. package/dist/src/commands/advisor.js +94 -0
  10. package/dist/src/commands/doctor.js +77 -3
  11. package/dist/src/commands/funnel.d.ts +13 -0
  12. package/dist/src/commands/funnel.js +103 -0
  13. package/dist/src/commands/hooks.js +112 -46
  14. package/dist/src/commands/index.js +10 -0
  15. package/dist/src/commands/init.js +44 -0
  16. package/dist/src/commands/proxy.d.ts +21 -0
  17. package/dist/src/commands/proxy.js +310 -0
  18. package/dist/src/commands/security.js +29 -0
  19. package/dist/src/commands/settings.d.ts +19 -0
  20. package/dist/src/commands/settings.js +180 -0
  21. package/dist/src/commands/version.d.ts +42 -0
  22. package/dist/src/commands/version.js +106 -0
  23. package/dist/src/funnel/advisor-tip.d.ts +58 -0
  24. package/dist/src/funnel/advisor-tip.js +92 -0
  25. package/dist/src/funnel/attribution.d.ts +37 -0
  26. package/dist/src/funnel/attribution.js +101 -0
  27. package/dist/src/funnel/consent.d.ts +22 -0
  28. package/dist/src/funnel/consent.js +55 -0
  29. package/dist/src/funnel/credit-errors.d.ts +31 -0
  30. package/dist/src/funnel/credit-errors.js +88 -0
  31. package/dist/src/funnel/credit-notifier.d.ts +44 -0
  32. package/dist/src/funnel/credit-notifier.js +74 -0
  33. package/dist/src/funnel/disclosure.d.ts +46 -0
  34. package/dist/src/funnel/disclosure.js +97 -0
  35. package/dist/src/funnel/enrollment.d.ts +36 -0
  36. package/dist/src/funnel/enrollment.js +64 -0
  37. package/dist/src/funnel/environment.d.ts +17 -0
  38. package/dist/src/funnel/environment.js +39 -0
  39. package/dist/src/funnel/event-transport.d.ts +51 -0
  40. package/dist/src/funnel/event-transport.js +199 -0
  41. package/dist/src/funnel/events.d.ts +42 -0
  42. package/dist/src/funnel/events.js +150 -0
  43. package/dist/src/funnel/index.d.ts +20 -0
  44. package/dist/src/funnel/index.js +20 -0
  45. package/dist/src/funnel/insights.d.ts +49 -0
  46. package/dist/src/funnel/insights.js +120 -0
  47. package/dist/src/funnel/local-signals.d.ts +15 -0
  48. package/dist/src/funnel/local-signals.js +68 -0
  49. package/dist/src/funnel/message-transport.d.ts +51 -0
  50. package/dist/src/funnel/message-transport.js +149 -0
  51. package/dist/src/funnel/messages.d.ts +55 -0
  52. package/dist/src/funnel/messages.js +160 -0
  53. package/dist/src/funnel/power-saver-notifier.d.ts +44 -0
  54. package/dist/src/funnel/power-saver-notifier.js +92 -0
  55. package/dist/src/funnel/precedence.d.ts +16 -0
  56. package/dist/src/funnel/precedence.js +85 -0
  57. package/dist/src/funnel/promo.d.ts +41 -0
  58. package/dist/src/funnel/promo.js +144 -0
  59. package/dist/src/funnel/rate-limit-notifier.d.ts +55 -0
  60. package/dist/src/funnel/rate-limit-notifier.js +102 -0
  61. package/dist/src/funnel/rotation.d.ts +19 -0
  62. package/dist/src/funnel/rotation.js +70 -0
  63. package/dist/src/funnel/state.d.ts +13 -0
  64. package/dist/src/funnel/state.js +52 -0
  65. package/dist/src/funnel/toggle-cooldown.d.ts +17 -0
  66. package/dist/src/funnel/toggle-cooldown.js +32 -0
  67. package/dist/src/funnel/types.d.ts +86 -0
  68. package/dist/src/funnel/types.js +26 -0
  69. package/dist/src/init/executor.js +9 -3
  70. package/dist/src/init/helper-refresh.d.ts +33 -2
  71. package/dist/src/init/helper-refresh.js +102 -12
  72. package/dist/src/init/helpers-generator.js +52 -0
  73. package/dist/src/init/statusline-generator.js +292 -116
  74. package/dist/src/services/daemon-autostart.js +28 -5
  75. package/dist/src/services/fable-harness.d.ts +39 -0
  76. package/dist/src/services/fable-harness.js +41 -0
  77. package/package.json +13 -4
  78. package/dist/src/ruvector/lattice-wasm.d.ts +0 -14
  79. package/dist/src/ruvector/lattice-wasm.js +0 -144
  80. package/plugins/ruflo-metaharness/.claude-flow/daemon-state.json +0 -178
  81. package/plugins/ruflo-metaharness/.claude-flow/daemon.pid +0 -1
  82. package/plugins/ruflo-metaharness/.claude-flow/data/pending-insights.jsonl +0 -5
  83. package/plugins/ruflo-metaharness/.claude-flow/logs/daemon.log +0 -43
  84. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/audit_1783604774864_ozbujc_prompt.log +0 -19
  85. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/audit_1783604774864_ozbujc_result.log +0 -108
  86. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/audit_1783605513587_ulvmpb_prompt.log +0 -19
  87. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/audit_1783605513587_ulvmpb_result.log +0 -209
  88. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/audit_1783606368867_ahysui_prompt.log +0 -19
  89. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/audit_1783606368867_ahysui_result.log +0 -192
  90. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/optimize_1783604894861_v6n3ut_prompt.log +0 -14
  91. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/optimize_1783604894861_v6n3ut_result.log +0 -66
  92. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/optimize_1783605934532_9h8ikb_prompt.log +0 -14
  93. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/optimize_1783605934532_9h8ikb_result.log +0 -68
  94. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/testgaps_1783605134860_9jssz9_prompt.log +0 -14
  95. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/testgaps_1783605134860_9jssz9_result.log +0 -69
  96. package/plugins/ruflo-metaharness/.claude-flow/logs/headless/testgaps_1783606516743_zftbaa_prompt.log +0 -14
  97. package/plugins/ruflo-metaharness/.claude-flow/metrics/backup.json +0 -7
  98. package/plugins/ruflo-metaharness/.claude-flow/metrics/codebase-map.json +0 -11
  99. package/plugins/ruflo-metaharness/.claude-flow/metrics/consolidation.json +0 -16
  100. package/plugins/ruflo-metaharness/.claude-flow/metrics/harness-loop.json +0 -83
  101. package/plugins/ruflo-metaharness/.claude-flow/metrics/performance.json +0 -67
  102. package/plugins/ruflo-metaharness/.claude-flow/metrics/security-audit.json +0 -191
  103. package/plugins/ruflo-metaharness/.claude-flow/metrics/test-gaps.json +0 -68
  104. package/plugins/ruflo-metaharness/.claude-flow/neural/stats.json +0 -6
@@ -1,604 +1,880 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * RuFlo V3.5 Statusline Generator
4
- * Displays real-time V3 implementation progress and system status
3
+ * RuFlo V3 Statusline — delegation build (#2195)
5
4
  *
6
- * Usage: node statusline.cjs [options]
5
+ * Fix for ruvnet/ruflo#2195: the previous version re-implemented all data
6
+ * readers locally using fragile file probes that missed AgentDB patterns,
7
+ * the v3/docs/adr/ ADR directory, and the real vector count.
7
8
  *
8
- * Options:
9
- * (default) Safe multi-line output with collision zone avoidance
10
- * --single Single-line output (completely avoids collision)
11
- * --unsafe Legacy multi-line without collision avoidance
12
- * --legacy Alias for --unsafe
13
- * --json JSON output with pretty printing
14
- * --compact JSON output without formatting
9
+ * This version delegates to 'npx @claude-flow/cli hooks statusline --json'
10
+ * as the single source of truth. That command queries AgentDB directly,
11
+ * counts ADRs in both directories, and reports the real intelligence pct.
15
12
  *
16
- * Collision Zone Fix (Issue #985):
17
- * Claude Code writes its internal status (e.g., "7s • 1p") at absolute
18
- * terminal coordinates (columns 15-25 on second-to-last line). The safe
19
- * mode pads the collision line with spaces to push content past column 25.
13
+ * ADR counting falls back to local file reads so the display still works
14
+ * without network access (counts both v3/docs/adr/ and v3/implementation/adrs/).
20
15
  *
21
- * IMPORTANT: This file uses .cjs extension to work in ES module projects.
22
- * The require() syntax is intentional for CommonJS compatibility.
16
+ * Cache: JSON result is cached in /tmp for 10s so rapid prompt triggers
17
+ * (every keystroke in some shells) don't hammer the CLI on every call.
18
+ *
19
+ * Usage: node statusline.cjs [--json] [--compact] [--dashboard]
23
20
  */
24
21
 
25
22
  /* eslint-disable @typescript-eslint/no-var-requires */
26
23
  const fs = require('fs');
27
24
  const path = require('path');
25
+ const { execSync } = require('child_process');
28
26
  const os = require('os');
29
- const { execSync, execFileSync } = require('child_process');
30
-
31
- // Read the installed plugin version once at startup. Probe the plugin's own
32
- // install location first (`~/.claude/plugins/marketplaces/ruflo/package.json`),
33
- // then npm-style installs, then the source-checkout location. The previous
34
- // code hardcoded `RuFlo V3.5` in the header — so users on alpha.27+ still
35
- // saw V3.5 in the statusline even though `ruflo doctor` reported the real
36
- // version (#1951).
37
- let RUFLO_VERSION = '3.6';
38
- try {
39
- const home = os.homedir();
40
- const cwd = process.cwd();
41
- const pkgPaths = [
42
- path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'package.json'),
43
- path.join(cwd, 'node_modules', '@claude-flow', 'cli', 'package.json'),
44
- path.join(cwd, 'node_modules', 'ruflo', 'package.json'),
45
- path.join(cwd, 'v3', '@claude-flow', 'cli', 'package.json'),
46
- ];
47
- for (const p of pkgPaths) {
48
- if (!fs.existsSync(p)) continue;
49
- try {
50
- const pkg = JSON.parse(fs.readFileSync(p, 'utf-8'));
51
- if (pkg && typeof pkg.version === 'string' && pkg.version.length > 0) {
52
- RUFLO_VERSION = pkg.version;
53
- break;
54
- }
55
- } catch { /* malformed package.json — try next */ }
56
- }
57
- } catch { /* fall through to the hardcoded default */ }
58
27
 
59
28
  // Configuration
60
29
  const CONFIG = {
61
- enabled: true,
62
- showProgress: true,
63
- showSecurity: true,
64
- showSwarm: true,
65
- showHooks: true,
66
- showPerformance: true,
67
- refreshInterval: 5000,
68
30
  maxAgents: 15,
69
- topology: 'hierarchical-mesh',
31
+ // Session-cost display. Claude Code's cost.total_cost_usd is a client-side
32
+ // estimate that "may differ from your actual bill" and reads as misleading on
33
+ // subscription plans, where token usage is not billed per dollar. These let
34
+ // each user pick what the segment means to them without changing the default.
35
+ // RUFLO_STATUSLINE_COST_SYMBOL override the leading '$' (e.g. ⚡, €, 🌱);
36
+ // set to an empty string for the number alone.
37
+ // RUFLO_STATUSLINE_HIDE_COST 1/true/yes/on removes the segment entirely.
38
+ costSymbol: process.env.RUFLO_STATUSLINE_COST_SYMBOL ?? '$',
39
+ hideCost: /^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_HIDE_COST || ''),
70
40
  };
71
41
 
72
- // Cross-platform helpers
73
- const isWindows = process.platform === 'win32';
74
- const nullDevice = isWindows ? 'NUL' : '/dev/null';
75
-
76
- // ANSI colors
77
- const c = {
78
- reset: '\x1b[0m',
79
- bold: '\x1b[1m',
80
- dim: '\x1b[2m',
81
- red: '\x1b[0;31m',
82
- green: '\x1b[0;32m',
83
- yellow: '\x1b[0;33m',
84
- blue: '\x1b[0;34m',
85
- purple: '\x1b[0;35m',
86
- cyan: '\x1b[0;36m',
87
- brightRed: '\x1b[1;31m',
88
- brightGreen: '\x1b[1;32m',
89
- brightYellow: '\x1b[1;33m',
90
- brightBlue: '\x1b[1;34m',
91
- brightPurple: '\x1b[1;35m',
92
- brightCyan: '\x1b[1;36m',
93
- brightWhite: '\x1b[1;37m',
94
- };
42
+ const CWD = process.cwd();
43
+
44
+ // ─── Delegation cache ───────────────────────────────────────────
45
+ // Cache the CLI JSON result for 60s so rapid prompt re-renders
46
+ // (Claude Code refreshes the statusline several times a second while
47
+ // streaming) don't re-invoke the CLI each time. #2337: bumped 10s→60s
48
+ // because 10s was far too short for how often Claude Code re-renders.
49
+ const CACHE_FILE = path.join(os.tmpdir(), 'ruflo-statusline-cache-' + require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) + '.json');
50
+ const CACHE_TTL_MS = 60000;
51
+
52
+ // The promo/insight row is designed to rotate on a 20s cadence (funnel/
53
+ // rotation.ts's ROTATION_SLOT_MS / funnel/promo.ts's insight-slot check —
54
+ // duplicated here as a bare number since this generated script has no
55
+ // runtime import of the funnel module; keep in sync if that constant ever
56
+ // changes). The rotation slot is only ever (re)computed SERVER-SIDE inside
57
+ // the CLI subprocess this file shells out to — so a general 60s data cache
58
+ // (correct and necessary for #2337) silently made that 20s design
59
+ // unreachable: cache.fresh stayed true across 2-3 whole rotation slots,
60
+ // so the row visibly "didn't rotate" (user report). Fix: track promo
61
+ // freshness on its OWN, tighter clock — when it lags behind the current
62
+ // slot, fall through to a real CLI call even though the REST of the
63
+ // cached data (security/swarm/system) is still within CACHE_TTL_MS. This
64
+ // does not touch or regress #2337's fix; it only adds a narrower check.
65
+ const PROMO_ROTATION_SLOT_MS = 20000;
66
+
67
+ // Persistent last-known-good promo record. Lives outside the /tmp cache so it
68
+ // survives a full cache wipe / cache write race / CLI failure combo. Written
69
+ // every time we successfully render a promo; read as a last resort so the row
70
+ // never blinks out mid-session (was: 'promo shows then hides' bug report).
71
+ const PROMO_MEMO_FILE = path.join(os.homedir(), '.ruflo', 'statusline-promo.json');
72
+ const PROMO_MEMO_TTL_MS = 6 * 60 * 60 * 1000; // 6h — long enough to bridge any hiccup, short enough that a real disable takes effect fast.
73
+
74
+ // #2337: resolve an already-installed @claude-flow/cli (or ruflo) bin so we
75
+ // can invoke it directly via `node`. The previous version called
76
+ // `npx --yes @claude-flow/cli@latest` on every uncached render, which forces
77
+ // a registry resolution + cold-start of the entire CLI per render. With
78
+ // multiple concurrent Claude Code sessions this storms the host (reporter
79
+ // saw load average 40-65 on a 12-core box).
80
+ //
81
+ // Returns EVERY existing bin/cli.js candidate, in preference order (project,
82
+ // monorepo, plugin marketplace, global node_modules including custom-prefix
83
+ // layouts like ~/.npm-global) — mirrors getPkgVersion()'s own path probing.
84
+ //
85
+ // Returns a list, not a single winner: `fs.existsSync` only proves a file is
86
+ // present, not that it actually runs. A marketplace/npx-cached install can
87
+ // exist on disk but be broken (observed in practice: a stale marketplace
88
+ // checkout whose dist/ imports a workspace package, '@claude-flow/cli-core',
89
+ // that isn't bundled there — every invocation throws ERR_MODULE_NOT_FOUND).
90
+ // Picking the first EXISTING path and never falling through meant a single
91
+ // broken install silently killed the promo row for the entire session (the
92
+ // CLI call always failed, so the memo could never refresh and eventually
93
+ // expired). getStatuslineData() now walks this whole list and tries the next
94
+ // candidate on failure, so one broken install can't permanently wedge it.
95
+ function resolveCliBinCandidates() {
96
+ const candidates = [];
97
+ try {
98
+ const home = os.homedir();
99
+ candidates.push(
100
+ path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
101
+ path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
102
+ path.join(CWD, 'node_modules', 'ruflo', 'bin', 'cli.js'),
103
+ path.join(CWD, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
104
+ );
105
+ try {
106
+ const binDir = path.dirname(process.execPath);
107
+ const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
108
+ for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
109
+ if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
110
+ }
111
+ for (const gm of globalModuleDirs) {
112
+ candidates.push(
113
+ path.join(gm, 'ruflo', 'bin', 'cli.js'),
114
+ path.join(gm, '@claude-flow', 'cli', 'bin', 'cli.js'),
115
+ );
116
+ }
117
+ } catch { /* ignore */ }
118
+ } catch { /* ignore */ }
119
+ return candidates.filter((p) => { try { return fs.existsSync(p); } catch { return false; } });
120
+ }
95
121
 
96
- // Get user info
97
- function getUserInfo() {
98
- let name = 'user';
99
- let gitBranch = '';
100
- let modelName = 'Unknown';
101
-
102
- // audit_1776853149979: previously used execSync with a shell string. Switched
103
- // to execFileSync('git', argv) on both platforms so there is no shell
104
- // interpretation. Cross-platform behavior is preserved by relying on git's
105
- // own exit code instead of `|| echo` fallbacks: missing repo / no user.name
106
- // throws, caught below, defaults retained.
122
+ // Return { fresh, promoFresh, data }. 'fresh' is true only if within the TTL
123
+ // — but data is returned regardless (stale-while-revalidate). This lets us
124
+ // serve last known state (specifically the promo row) when the CLI is
125
+ // slow/unavailable, so users don't see the funnel row flicker in and out on
126
+ // cache expiry. 'promoFresh' is a SEPARATE, tighter check on the same clock
127
+ // as PROMO_ROTATION_SLOT_MS — see that constant's comment for why the promo
128
+ // row needs its own freshness bound distinct from the general 60s TTL.
129
+ function readCache() {
107
130
  try {
108
- name = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || 'user';
109
- } catch { /* keep default */ }
131
+ if (fs.existsSync(CACHE_FILE)) {
132
+ const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
133
+ if (raw && raw._ts && raw.data) {
134
+ const age = Date.now() - raw._ts;
135
+ return { fresh: age < CACHE_TTL_MS, promoFresh: age < PROMO_ROTATION_SLOT_MS, data: raw.data };
136
+ }
137
+ }
138
+ } catch { /* ignore */ }
139
+ return { fresh: false, promoFresh: false, data: null };
140
+ }
141
+
142
+ function writeCache(data) {
143
+ try { fs.writeFileSync(CACHE_FILE, JSON.stringify({ _ts: Date.now(), data }), 'utf-8'); } catch { /* ignore */ }
144
+ // Also memoize any promo we saw so the row can survive future CLI hiccups.
110
145
  try {
111
- gitBranch = execFileSync('git', ['branch', '--show-current'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
112
- } catch { /* keep empty */ }
146
+ if (data && data.promo && typeof data.promo === 'object') {
147
+ fs.mkdirSync(path.dirname(PROMO_MEMO_FILE), { recursive: true, mode: 0o700 });
148
+ fs.writeFileSync(PROMO_MEMO_FILE, JSON.stringify({ _ts: Date.now(), promo: data.promo }), { encoding: 'utf-8', mode: 0o600 });
149
+ }
150
+ } catch { /* ignore */ }
151
+ }
113
152
 
114
- // Auto-detect model from Claude Code's config
153
+ // Last resort: read a memoized promo (up to 6h old). Used when no cache and
154
+ // no CLI response is available — the row still renders, so users don't see
155
+ // the disclosure blink out. Returns null when the memo is absent, expired,
156
+ // or malformed. Never throws.
157
+ function readPromoMemo() {
115
158
  try {
116
- const homedir = require('os').homedir();
117
- const claudeConfigPath = path.join(homedir, '.claude.json');
118
- if (fs.existsSync(claudeConfigPath)) {
119
- const claudeConfig = JSON.parse(fs.readFileSync(claudeConfigPath, 'utf-8'));
120
- // Try to find lastModelUsage - check current dir and parent dirs
121
- let lastModelUsage = null;
122
- const cwd = process.cwd();
123
- if (claudeConfig.projects) {
124
- // Try exact match first, then check if cwd starts with any project path
125
- for (const [projectPath, projectConfig] of Object.entries(claudeConfig.projects)) {
126
- if (cwd === projectPath || cwd.startsWith(projectPath + '/')) {
127
- lastModelUsage = projectConfig.lastModelUsage;
128
- break;
129
- }
130
- }
131
- }
132
- if (lastModelUsage) {
133
- const modelIds = Object.keys(lastModelUsage);
134
- if (modelIds.length > 0) {
135
- // Take the last model (most recently added to the object)
136
- // Or find the one with most tokens (most actively used this session)
137
- let modelId = modelIds[modelIds.length - 1];
138
- if (modelIds.length > 1) {
139
- // If multiple models, pick the one with most total tokens
140
- let maxTokens = 0;
141
- for (const id of modelIds) {
142
- const usage = lastModelUsage[id];
143
- const total = (usage.inputTokens || 0) + (usage.outputTokens || 0);
144
- if (total > maxTokens) {
145
- maxTokens = total;
146
- modelId = id;
147
- }
148
- }
149
- }
150
- // Parse model ID to human-readable name
151
- if (modelId.includes('opus')) modelName = 'Opus 4.6 (1M context)';
152
- else if (modelId.includes('sonnet')) modelName = 'Sonnet 4.6';
153
- else if (modelId.includes('haiku')) modelName = 'Haiku 4.5';
154
- else modelName = modelId.split('-').slice(1, 3).join(' ');
155
- }
156
- }
159
+ if (!fs.existsSync(PROMO_MEMO_FILE)) return null;
160
+ const raw = JSON.parse(fs.readFileSync(PROMO_MEMO_FILE, 'utf-8'));
161
+ if (raw && raw._ts && (Date.now() - raw._ts) < PROMO_MEMO_TTL_MS && raw.promo) {
162
+ return raw.promo;
157
163
  }
158
- } catch (e) {
159
- // Fallback to Unknown if can't read config
160
- }
164
+ } catch { /* ignore */ }
165
+ return null;
166
+ }
161
167
 
162
- return { name, gitBranch, modelName };
168
+ /**
169
+ * Single source of truth: delegate to the CLI hooks statusline --json command.
170
+ * Falls back to a minimal static object on failure so the statusline still renders.
171
+ *
172
+ * Fix for ruflo#2195: the previous local readers returned 0 for AgentDB patterns
173
+ * (missed the .swarm/memory.db → AgentDB path), computed dddProgress wrong,
174
+ * and only counted ADRs in v3/implementation/adrs/ (missed v3/docs/adr/).
175
+ */
176
+ // Overlay the memoized promo onto any data object that's missing one. This is
177
+ // the safety net that keeps the funnel row rendered when an OLDER cached CLI
178
+ // version is picked up by npx — that older CLI succeeds but omits promo, so
179
+ // the JSON round-trips clean but without our row. We patch it back here.
180
+ function overlayMemoPromo(data) {
181
+ if (data && !data.promo) {
182
+ const memoPromo = readPromoMemo();
183
+ if (memoPromo) data.promo = memoPromo;
184
+ }
185
+ return data;
163
186
  }
164
187
 
165
- // Get learning stats from intelligence loop data (ADR-050)
166
- function getLearningStats() {
167
- let patterns = 0;
168
- let sessions = 0;
169
- let trajectories = 0;
170
- let edges = 0;
171
- let confidenceMean = 0;
172
- let accessedCount = 0;
173
- let trend = 'STABLE';
174
-
175
- // PRIMARY: Read from intelligence loop data files
176
- const dataDir = path.join(process.cwd(), '.claude-flow', 'data');
177
-
178
- // 1. graph-state.json authoritative node/edge counts
179
- const graphPath = path.join(dataDir, 'graph-state.json');
180
- if (fs.existsSync(graphPath)) {
188
+ function getStatuslineData() {
189
+ const cache = readCache();
190
+ // Both clocks must be satisfied to skip the CLI call entirely: the general
191
+ // 60s TTL (#2337 — don't re-spawn the CLI on every rapid re-render) AND the
192
+ // tighter promo-rotation clock (this fix — don't let a still-fresh 60s
193
+ // cache silently freeze the promo/insight row across multiple 20s slots).
194
+ if (cache.fresh && cache.promoFresh) return overlayMemoPromo(cache.data);
195
+
196
+ // #2337: prefer an already-installed CLI bin via direct `node` invocation —
197
+ // no npx, no registry round-trip, no @latest re-resolve per render. Try
198
+ // every candidate that actually EXISTS (not just the first) before falling
199
+ // back to `npx --prefer-offline @claude-flow/cli` (no @latest); an existing
200
+ // but broken install (e.g. a stale marketplace checkout missing a bundled
201
+ // workspace dep) must not block trying the next one.
202
+ //
203
+ // No `2>/dev/null` here (deliberately) — the execSync call below already
204
+ // sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
205
+ // Node level regardless of shell. The redirect was redundant on POSIX and
206
+ // actively broke every candidate on Windows: cmd.exe (execSync's default
207
+ // shell there) doesn't understand /dev/null, so the CLI delegation always
208
+ // failed, silently degrading every render to buildLocalFallback() — 0%
209
+ // intelligence and an empty promo row (the memo cache that keeps the row
210
+ // populated across CLI hiccups is only ever written from a SUCCESSFUL
211
+ // delegation, so it could never get seeded on Windows either).
212
+ const cmds = resolveCliBinCandidates()
213
+ .map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
214
+ .concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
215
+ for (const cmd of cmds) {
181
216
  try {
182
- const graph = JSON.parse(fs.readFileSync(graphPath, 'utf-8'));
183
- patterns = graph.nodes ? Object.keys(graph.nodes).length : 0;
184
- edges = Array.isArray(graph.edges) ? graph.edges.length : 0;
185
- } catch (e) { /* ignore */ }
217
+ const raw = execSync(
218
+ cmd,
219
+ { encoding: 'utf-8', timeout: 8000, stdio: ['pipe', 'pipe', 'pipe'], cwd: CWD }
220
+ ).trim();
221
+ // The CLI may emit preamble lines before the JSON — find the first '{'.
222
+ const jsonStart = raw.indexOf('{');
223
+ if (jsonStart === -1) throw new Error('no JSON in CLI output');
224
+ const data = JSON.parse(raw.slice(jsonStart));
225
+ // Overlay every block the CLI JSON omits (adrs/agentdb/tests/hooks/integration)
226
+ // with real local reads, so those segments reflect actual state instead of 0.
227
+ applyLocalOverlays(data);
228
+ overlayMemoPromo(data);
229
+ writeCache(data);
230
+ return data;
231
+ } catch { /* this candidate unavailable, broken, or timed out — try the next */ }
186
232
  }
187
233
 
188
- // 2. ranked-context.json confidence and access data
189
- const rankedPath = path.join(dataDir, 'ranked-context.json');
190
- if (fs.existsSync(rankedPath)) {
191
- try {
192
- const ranked = JSON.parse(fs.readFileSync(rankedPath, 'utf-8'));
193
- if (ranked.entries && ranked.entries.length > 0) {
194
- patterns = Math.max(patterns, ranked.entries.length);
195
- let confSum = 0;
196
- let accCount = 0;
197
- for (let i = 0; i < ranked.entries.length; i++) {
198
- confSum += (ranked.entries[i].confidence || 0);
199
- if ((ranked.entries[i].accessCount || 0) > 0) accCount++;
200
- }
201
- confidenceMean = confSum / ranked.entries.length;
202
- accessedCount = accCount;
203
- }
204
- } catch (e) { /* ignore */ }
234
+ // Stale-while-revalidate: if we have any cached data, keep serving it so the
235
+ // funnel row doesn't flicker on CLI hiccups. Overlay fresh local reads for
236
+ // the segments the CLI JSON doesn't populate; the promo row survives.
237
+ if (cache.data) {
238
+ applyLocalOverlays(cache.data);
239
+ overlayMemoPromo(cache.data);
240
+ return cache.data;
205
241
  }
206
242
 
207
- // 3. intelligence-snapshot.json trend history
208
- const snapshotPath = path.join(dataDir, 'intelligence-snapshot.json');
209
- if (fs.existsSync(snapshotPath)) {
243
+ // Last resort: local probes + memo. Users still see the funnel row.
244
+ return overlayMemoPromo(buildLocalFallback());
245
+ }
246
+
247
+ // Count ADRs from BOTH known directories (fix for ruflo#2195: old code missed
248
+ // v3/docs/adr/ which holds ADR-088..ADR-137, i.e. 41 of the 128 total ADRs).
249
+ function getLocalADRCount() {
250
+ const adrDirs = [
251
+ path.join(CWD, 'v3', 'implementation', 'adrs'),
252
+ path.join(CWD, 'v3', 'docs', 'adr'),
253
+ path.join(CWD, 'docs', 'adrs'),
254
+ path.join(CWD, '.claude-flow', 'adrs'),
255
+ ];
256
+ let total = 0;
257
+ for (const dir of adrDirs) {
210
258
  try {
211
- const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
212
- if (snapshot.history && snapshot.history.length >= 2) {
213
- const first = snapshot.history[0];
214
- const last = snapshot.history[snapshot.history.length - 1];
215
- const confDrift = (last.confidenceMean || 0) - (first.confidenceMean || 0);
216
- trend = confDrift > 0.01 ? 'IMPROVING' : confDrift < -0.01 ? 'DECLINING' : 'STABLE';
217
- sessions = Math.max(sessions, snapshot.history.length);
259
+ if (fs.existsSync(dir)) {
260
+ const files = fs.readdirSync(dir).filter(function(f) {
261
+ return f.endsWith('.md') && (f.startsWith('ADR-') || f.startsWith('adr-') || /^\d{4}-/.test(f));
262
+ });
263
+ total += files.length;
218
264
  }
219
- } catch (e) { /* ignore */ }
265
+ } catch { /* ignore */ }
220
266
  }
267
+ return { count: total, implemented: total, compliance: 0 };
268
+ }
221
269
 
222
- // 4. auto-memory-store.json fallback entry count
223
- if (patterns === 0) {
224
- const autoMemPath = path.join(dataDir, 'auto-memory-store.json');
225
- if (fs.existsSync(autoMemPath)) {
226
- try {
227
- const data = JSON.parse(fs.readFileSync(autoMemPath, 'utf-8'));
228
- patterns = Array.isArray(data) ? data.length : (data.entries ? data.entries.length : 0);
229
- } catch (e) { /* ignore */ }
230
- }
231
- }
270
+ // ─── Local overlays for segments the CLI JSON omits ──────────────
271
+ // 'hooks statusline --json' only returns user/v3Progress/security/swarm/system.
272
+ // agentdb/tests/hooks/integration are never populated, so without these overlays
273
+ // they render as a permanent 0. Each reader is cheap and degrades to zeros.
232
274
 
233
- // FALLBACK: Legacy memory.db file-size estimation
234
- if (patterns === 0) {
235
- const memoryPaths = [
236
- path.join(process.cwd(), '.swarm', 'memory.db'),
237
- path.join(process.cwd(), '.claude', 'memory.db'),
238
- path.join(process.cwd(), 'data', 'memory.db'),
239
- ];
240
- for (let j = 0; j < memoryPaths.length; j++) {
241
- if (fs.existsSync(memoryPaths[j])) {
242
- try {
243
- const dbStats = fs.statSync(memoryPaths[j]);
244
- patterns = Math.floor(dbStats.size / 1024 / 2);
245
- break;
246
- } catch (e) { /* ignore */ }
247
- }
275
+ // Real AgentDB stats from the local memory DB. Vectors live in .swarm/memory.db
276
+ // (sql.js + HNSW); ruvector.db is an opaque redb store counted only toward size.
277
+ // One read-only sqlite3 query (mode=ro never takes a write lock the daemon owns).
278
+ function getLocalAgentDB() {
279
+ const result = { vectorCount: 0, dbSizeKB: 0, hasHnsw: false };
280
+ try {
281
+ let bytes = 0;
282
+ for (const f of ['.swarm/memory.db', 'ruvector.db']) {
283
+ try { bytes += fs.statSync(path.join(CWD, f)).size; } catch { /* missing */ }
248
284
  }
249
- }
285
+ result.dbSizeKB = Math.round(bytes / 1024);
286
+
287
+ const memDb = path.join(CWD, '.swarm', 'memory.db');
288
+ if (fs.existsSync(memDb)) {
289
+ const Q = String.fromCharCode(34);
290
+ // Two INDEPENDENT statements -- do NOT combine into one. Coupling the
291
+ // vector count with the vector_indexes row count in a single statement
292
+ // meant that on a DB missing the vector_indexes table (older/agentdb-
293
+ // written DBs), the whole statement failed at PREPARE time (SQLite
294
+ // compiles the full SQL before running), so the valid memory_entries
295
+ // count was discarded too and the statusline showed Vectors 0 despite
296
+ // thousands of real vectors. Split so a missing table can only zero the
297
+ // HNSW flag, never the count. The init self-heal provisions the table so
298
+ // the flag recovers on the next ruflo init / MCP start.
299
+ const countSql = Q + 'SELECT COUNT(*) FROM memory_entries WHERE embedding IS NOT NULL;' + Q;
300
+ const vc = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + countSql, 1500);
301
+ if (vc) result.vectorCount = parseInt(vc, 10) || 0;
302
+ // HNSW flag: separate statement. If vector_indexes is absent, sqlite3
303
+ // exits non-zero and safeExec returns empty -- hasHnsw stays false (exact
304
+ // original semantics: at least one index-config row present).
305
+ const hnswSql = Q + 'SELECT COUNT(*) FROM vector_indexes;' + Q;
306
+ const hn = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + hnswSql, 1500);
307
+ if (hn) result.hasHnsw = (parseInt(hn, 10) || 0) > 0;
308
+ }
309
+ } catch { /* ignore */ }
310
+ return result;
311
+ }
250
312
 
251
- // Session count from session files
252
- const sessionsPath = path.join(process.cwd(), '.claude', 'sessions');
253
- if (fs.existsSync(sessionsPath)) {
313
+ // Count test files via a bounded directory walk (no file reads).
314
+ function getLocalTests() {
315
+ let testFiles = 0;
316
+ function countTests(dir, depth) {
317
+ if ((depth || 0) > 4) return;
254
318
  try {
255
- const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.json'));
256
- sessions = Math.max(sessions, sessionFiles.length);
257
- } catch (e) { /* ignore */ }
319
+ if (!fs.existsSync(dir)) return;
320
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
321
+ if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
322
+ countTests(path.join(dir, e.name), (depth || 0) + 1);
323
+ } else if (e.isFile() && (e.name.includes('.test.') || e.name.includes('.spec.') || e.name.startsWith('test_') || e.name.startsWith('spec_'))) {
324
+ testFiles++;
325
+ }
326
+ }
327
+ } catch { /* ignore */ }
258
328
  }
329
+ for (const d of ['tests', 'test', '__tests__', 'src', 'v3']) countTests(path.join(CWD, d));
330
+ return { testFiles, testCases: testFiles * 4 };
331
+ }
259
332
 
260
- trajectories = Math.floor(patterns / 5);
333
+ // Count configured hooks from project .claude/settings.json. Claude Code hooks
334
+ // have no enabled/disabled flag, so every configured hook counts as enabled.
335
+ function getLocalHooks() {
336
+ const result = { enabled: 0, total: 0 };
337
+ try {
338
+ const settings = readJSON(path.join(CWD, '.claude', 'settings.json'));
339
+ const hooks = settings && settings.hooks;
340
+ if (hooks && typeof hooks === 'object') {
341
+ let n = 0;
342
+ for (const ev of Object.keys(hooks)) {
343
+ const groups = hooks[ev];
344
+ if (Array.isArray(groups)) {
345
+ for (const g of groups) {
346
+ if (g && Array.isArray(g.hooks)) n += g.hooks.length;
347
+ }
348
+ }
349
+ }
350
+ result.total = n;
351
+ result.enabled = n;
352
+ }
353
+ } catch { /* ignore */ }
354
+ return result;
355
+ }
261
356
 
262
- return { patterns, sessions, trajectories, edges, confidenceMean, accessedCount, trend };
357
+ // Best-effort integration block: DB presence + locally-configured stdio MCP
358
+ // servers (project .mcp.json + global ~/.claude.json). Remote connectors are
359
+ // account-managed and not present in local config, so they are not counted.
360
+ function getLocalIntegration() {
361
+ const integration = { mcpServers: { enabled: 0, total: 0 }, hasDatabase: false };
362
+ try {
363
+ for (const f of ['.swarm/memory.db', 'ruvector.db']) {
364
+ if (fs.existsSync(path.join(CWD, f))) { integration.hasDatabase = true; break; }
365
+ }
366
+ const names = new Set();
367
+ const projMcp = readJSON(path.join(CWD, '.mcp.json'));
368
+ if (projMcp && projMcp.mcpServers) for (const k of Object.keys(projMcp.mcpServers)) names.add(k);
369
+ const claudeJson = readJSON(path.join(os.homedir(), '.claude.json'));
370
+ if (claudeJson) {
371
+ if (claudeJson.mcpServers) for (const k of Object.keys(claudeJson.mcpServers)) names.add(k);
372
+ const proj = claudeJson.projects && claudeJson.projects[CWD];
373
+ if (proj && proj.mcpServers && !Array.isArray(proj.mcpServers)) {
374
+ for (const k of Object.keys(proj.mcpServers)) names.add(k);
375
+ }
376
+ }
377
+ integration.mcpServers.total = names.size;
378
+ integration.mcpServers.enabled = names.size;
379
+ } catch { /* ignore */ }
380
+ return integration;
263
381
  }
264
382
 
265
- // Get V3 progress from learning state (grows as system learns)
266
- function getV3Progress() {
267
- const learning = getLearningStats();
268
-
269
- // DDD progress based on actual learned patterns
270
- // New install: 0 patterns = 0/5 domains, 0% DDD
271
- // As patterns grow: 10+ patterns = 1 domain, 50+ = 2, 100+ = 3, 200+ = 4, 500+ = 5
272
- let domainsCompleted = 0;
273
- if (learning.patterns >= 500) domainsCompleted = 5;
274
- else if (learning.patterns >= 200) domainsCompleted = 4;
275
- else if (learning.patterns >= 100) domainsCompleted = 3;
276
- else if (learning.patterns >= 50) domainsCompleted = 2;
277
- else if (learning.patterns >= 10) domainsCompleted = 1;
278
-
279
- const totalDomains = 5;
280
- const dddProgress = Math.min(100, Math.floor((domainsCompleted / totalDomains) * 100));
281
-
282
- return {
283
- domainsCompleted,
284
- totalDomains,
285
- dddProgress,
286
- patternsLearned: learning.patterns,
287
- sessionsCompleted: learning.sessions
288
- };
383
+ // Overlay every locally-derived block onto the CLI data (mutates in place).
384
+ function applyLocalOverlays(data) {
385
+ data.adrs = getLocalADRCount();
386
+ data.agentdb = getLocalAgentDB();
387
+ data.tests = getLocalTests();
388
+ data.hooks = getLocalHooks();
389
+ data.integration = getLocalIntegration();
390
+ return data;
289
391
  }
290
392
 
291
- // Get security status based on actual scans
292
- function getSecurityStatus() {
293
- // Check for security scan results in memory
294
- const scanResultsPath = path.join(process.cwd(), '.claude', 'security-scans');
295
- let cvesFixed = 0;
296
- const totalCves = 3;
393
+ // Minimal local fallback when the CLI is not installed or times out.
394
+ // Returns a structure that matches the CLI JSON schema so the renderer works.
395
+ function buildLocalFallback() {
396
+ const memMB = Math.floor(process.memoryUsage().heapUsed / 1024 / 1024);
397
+
398
+ return applyLocalOverlays({
399
+ user: { name: 'user', gitBranch: '', modelName: 'Claude Code' },
400
+ v3Progress: { domainsCompleted: 0, totalDomains: 5, dddProgress: 0, patternsLearned: 0, sessionsCompleted: 0 },
401
+ security: { status: 'NONE', cvesFixed: 0, totalCves: 0 },
402
+ swarm: { activeAgents: 0, maxAgents: CONFIG.maxAgents, coordinationActive: false },
403
+ system: { memoryMB: memMB, contextPct: 0, intelligencePct: 0, subAgents: 0 },
404
+ lastUpdated: new Date().toISOString(),
405
+ });
406
+ }
297
407
 
298
- if (fs.existsSync(scanResultsPath)) {
299
- try {
300
- const scans = fs.readdirSync(scanResultsPath).filter(f => f.endsWith('.json'));
301
- // Each successful scan file = 1 CVE addressed
302
- cvesFixed = Math.min(totalCves, scans.length);
303
- } catch (e) {
304
- // Ignore
305
- }
408
+ // ANSI colors
409
+ const c = {
410
+ reset: '\x1b[0m',
411
+ bold: '\x1b[1m',
412
+ dim: '\x1b[2m',
413
+ red: '\x1b[0;31m',
414
+ green: '\x1b[0;32m',
415
+ yellow: '\x1b[0;33m',
416
+ blue: '\x1b[0;34m',
417
+ purple: '\x1b[0;35m',
418
+ cyan: '\x1b[0;36m',
419
+ brightRed: '\x1b[1;31m',
420
+ brightGreen: '\x1b[1;32m',
421
+ brightYellow: '\x1b[1;33m',
422
+ brightBlue: '\x1b[1;34m',
423
+ brightPurple: '\x1b[1;35m',
424
+ brightCyan: '\x1b[1;36m',
425
+ brightWhite: '\x1b[1;37m',
426
+ };
427
+
428
+ // Safe execSync with strict timeout (returns empty string on failure)
429
+ function safeExec(cmd, timeoutMs) {
430
+ try {
431
+ return execSync(cmd, {
432
+ encoding: 'utf-8',
433
+ timeout: timeoutMs || 2000,
434
+ stdio: ['pipe', 'pipe', 'pipe'],
435
+ }).trim();
436
+ } catch {
437
+ return '';
306
438
  }
439
+ }
307
440
 
308
- // Also check .swarm/security for audit results
309
- const auditPath = path.join(process.cwd(), '.swarm', 'security');
310
- if (fs.existsSync(auditPath)) {
311
- try {
312
- const audits = fs.readdirSync(auditPath).filter(f => f.includes('audit'));
313
- cvesFixed = Math.min(totalCves, Math.max(cvesFixed, audits.length));
314
- } catch (e) {
315
- // Ignore
441
+ // Safe JSON file reader (returns null on failure)
442
+ function readJSON(filePath) {
443
+ try {
444
+ if (fs.existsSync(filePath)) {
445
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
316
446
  }
317
- }
447
+ } catch { /* ignore */ }
448
+ return null;
449
+ }
318
450
 
319
- const status = cvesFixed >= totalCves ? 'CLEAN' : cvesFixed > 0 ? 'IN_PROGRESS' : 'PENDING';
451
+ // ─── Git info (pure-Node / single exec needed for branch display) ──────────
320
452
 
321
- return {
322
- status,
323
- cvesFixed,
324
- totalCves,
453
+ function getGitInfo() {
454
+ const result = {
455
+ name: 'user', gitBranch: '', modified: 0, untracked: 0,
456
+ staged: 0, ahead: 0, behind: 0,
325
457
  };
326
- }
327
-
328
- // Get swarm status
329
- function getSwarmStatus() {
330
- let activeAgents = 0;
331
- let coordinationActive = false;
332
458
 
333
- try {
334
- if (isWindows) {
335
- // Windows: use tasklist and findstr
336
- const ps = execSync('tasklist 2>NUL | findstr /I "agentic-flow" 2>NUL | find /C /V "" 2>NUL || echo 0', { encoding: 'utf-8' });
337
- activeAgents = Math.max(0, parseInt(ps.trim()) || 0);
338
- } else {
339
- const ps = execSync('ps aux 2>/dev/null | grep -c agentic-flow || echo "0"', { encoding: 'utf-8' });
340
- activeAgents = Math.max(0, parseInt(ps.trim()) - 1);
459
+ const script = [
460
+ 'git config user.name 2>/dev/null || echo user',
461
+ 'echo "---SEP---"',
462
+ 'git branch --show-current 2>/dev/null',
463
+ 'echo "---SEP---"',
464
+ 'git status --porcelain 2>/dev/null',
465
+ 'echo "---SEP---"',
466
+ 'git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"',
467
+ ].join('; ');
468
+
469
+ const raw = safeExec("sh -c '" + script + "'", 3000);
470
+ if (!raw) return result;
471
+
472
+ const parts = raw.split('---SEP---').map(function(s) { return s.trim(); });
473
+ if (parts.length >= 4) {
474
+ result.name = parts[0] || 'user';
475
+ result.gitBranch = parts[1] || '';
476
+
477
+ if (parts[2]) {
478
+ for (const line of parts[2].split('\n')) {
479
+ if (!line || line.length < 2) continue;
480
+ const x = line[0], y = line[1];
481
+ if (x === '?' && y === '?') { result.untracked++; continue; }
482
+ if (x !== ' ' && x !== '?') result.staged++;
483
+ if (y !== ' ' && y !== '?') result.modified++;
484
+ }
341
485
  }
342
- coordinationActive = activeAgents > 0;
343
- } catch (e) {
344
- // Ignore errors - default to 0 agents
486
+
487
+ const ab = (parts[3] || '0 0').split(/\s+/);
488
+ result.ahead = parseInt(ab[0]) || 0;
489
+ result.behind = parseInt(ab[1]) || 0;
345
490
  }
346
491
 
347
- return {
348
- activeAgents,
349
- maxAgents: CONFIG.maxAgents,
350
- coordinationActive,
351
- };
492
+ return result;
352
493
  }
353
494
 
354
- // Get system metrics (dynamic based on actual state)
355
- function getSystemMetrics() {
356
- let memoryMB = 0;
357
- let subAgents = 0;
358
-
495
+ // Detect model name from Claude config (pure file reads, no exec)
496
+ function getModelName() {
359
497
  try {
360
- if (isWindows) {
361
- // Windows: use tasklist for memory info, fallback to process.memoryUsage
362
- // tasklist memory column is complex to parse, use Node.js API instead
363
- memoryMB = Math.floor(process.memoryUsage().heapUsed / 1024 / 1024);
364
- } else {
365
- const mem = execSync('ps aux | grep -E "(node|agentic|claude)" | grep -v grep | awk \'{sum += $6} END {print int(sum/1024)}\'', { encoding: 'utf-8' });
366
- memoryMB = parseInt(mem.trim()) || 0;
498
+ const claudeConfig = readJSON(path.join(os.homedir(), '.claude.json'));
499
+ if (claudeConfig && claudeConfig.projects) {
500
+ for (const [projectPath, projectConfig] of Object.entries(claudeConfig.projects)) {
501
+ if (CWD === projectPath || CWD.startsWith(projectPath + '/')) {
502
+ const usage = projectConfig.lastModelUsage;
503
+ if (usage) {
504
+ const ids = Object.keys(usage);
505
+ if (ids.length > 0) {
506
+ let modelId = ids[ids.length - 1];
507
+ let latest = 0;
508
+ for (const id of ids) {
509
+ const ts = usage[id] && usage[id].lastUsedAt ? new Date(usage[id].lastUsedAt).getTime() : 0;
510
+ if (ts > latest) { latest = ts; modelId = id; }
511
+ }
512
+ if (modelId.includes('opus')) return 'Opus 4.8';
513
+ if (modelId.includes('sonnet')) return 'Sonnet 4.6';
514
+ if (modelId.includes('haiku')) return 'Haiku 4.5';
515
+ return modelId.split('-').slice(1, 3).join(' ');
516
+ }
517
+ }
518
+ break;
519
+ }
520
+ }
367
521
  }
368
- } catch (e) {
369
- // Fallback
370
- memoryMB = Math.floor(process.memoryUsage().heapUsed / 1024 / 1024);
522
+ } catch { /* ignore */ }
523
+
524
+ // Fallback: settings.json model field
525
+ const settings = getSettings();
526
+ if (settings && settings.model) {
527
+ const m = settings.model;
528
+ if (m.includes('opus')) return 'Opus 4.8';
529
+ if (m.includes('sonnet')) return 'Sonnet 4.6';
530
+ if (m.includes('haiku')) return 'Haiku 4.5';
371
531
  }
532
+ return 'Claude Code';
533
+ }
372
534
 
373
- // Get learning stats for intelligence %
374
- const learning = getLearningStats();
375
-
376
- // Intelligence % from REAL intelligence loop data (ADR-050)
377
- // Composite: 40% confidence mean + 30% access ratio + 30% pattern density
378
- let intelligencePct = 0;
379
- if (learning.confidenceMean > 0 || (learning.patterns > 0 && learning.accessedCount > 0)) {
380
- const confScore = Math.min(100, Math.floor(learning.confidenceMean * 100));
381
- const accessRatio = learning.patterns > 0 ? (learning.accessedCount / learning.patterns) : 0;
382
- const accessScore = Math.min(100, Math.floor(accessRatio * 100));
383
- const densityScore = Math.min(100, Math.floor(learning.patterns / 5));
384
- intelligencePct = Math.floor(confScore * 0.4 + accessScore * 0.3 + densityScore * 0.3);
535
+ // ─── Stdin reader (Claude Code pipes session JSON) ──────────────
536
+ // Claude Code sends session JSON via stdin. Read synchronously so the
537
+ // script works both when invoked by Claude Code (stdin has JSON) and
538
+ // when run manually from terminal (stdin is empty/tty).
539
+ let _stdinData = null;
540
+ function getStdinData() {
541
+ if (_stdinData !== undefined && _stdinData !== null) return _stdinData;
542
+ try {
543
+ if (process.stdin.isTTY) { _stdinData = null; return null; }
544
+ const chunks = [];
545
+ const buf = Buffer.alloc(4096);
546
+ let bytesRead;
547
+ try {
548
+ while ((bytesRead = fs.readSync(0, buf, 0, buf.length, null)) > 0) {
549
+ chunks.push(buf.slice(0, bytesRead));
550
+ }
551
+ } catch { /* EOF or read error */ }
552
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
553
+ _stdinData = (raw && raw.startsWith('{')) ? JSON.parse(raw) : null;
554
+ } catch {
555
+ _stdinData = null;
385
556
  }
386
- // Fallback: legacy pattern count
387
- if (intelligencePct === 0 && learning.patterns > 0) {
388
- intelligencePct = Math.min(100, Math.floor(learning.patterns / 10));
557
+ return _stdinData;
558
+ }
559
+
560
+ function getModelFromStdin() {
561
+ const data = getStdinData();
562
+ return (data && data.model && data.model.display_name) ? data.model.display_name : null;
563
+ }
564
+
565
+ function getContextFromStdin() {
566
+ const data = getStdinData();
567
+ if (data && data.context_window) {
568
+ return { usedPct: Math.floor(data.context_window.used_percentage || 0) };
389
569
  }
570
+ return null;
571
+ }
390
572
 
391
- // Context % based on session history
392
- const contextPct = Math.min(100, Math.floor(learning.sessions * 5));
573
+ function getCostFromStdin() {
574
+ const data = getStdinData();
575
+ if (data && data.cost) {
576
+ const durationMs = data.cost.total_duration_ms || 0;
577
+ const mins = Math.floor(durationMs / 60000);
578
+ const secs = Math.floor((durationMs % 60000) / 1000);
579
+ return {
580
+ costUsd: data.cost.total_cost_usd || 0,
581
+ duration: mins > 0 ? mins + 'm' + secs + 's' : secs + 's',
582
+ };
583
+ }
584
+ return null;
585
+ }
393
586
 
394
- // Count active sub-agents from process list
587
+ // Read package version from the first package.json we find.
588
+ function getPkgVersion() {
589
+ let ver = '3.6';
395
590
  try {
396
- if (isWindows) {
397
- // Windows: use tasklist and findstr for agent counting
398
- const agents = execSync('tasklist 2>NUL | findstr /I "claude-flow" 2>NUL | find /C /V "" 2>NUL || echo 0', { encoding: 'utf-8' });
399
- subAgents = Math.max(0, parseInt(agents.trim()) || 0);
400
- } else {
401
- const agents = execSync('ps aux 2>/dev/null | grep -c "claude-flow.*agent" || echo "0"', { encoding: 'utf-8' });
402
- subAgents = Math.max(0, parseInt(agents.trim()) - 1);
591
+ const home = os.homedir();
592
+ const pkgPaths = [
593
+ path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'package.json'),
594
+ path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'package.json'),
595
+ path.join(CWD, 'node_modules', 'ruflo', 'package.json'),
596
+ path.join(CWD, 'v3', '@claude-flow', 'cli', 'package.json'),
597
+ ];
598
+ // #2221: global installs (npm i -g ruflo) live outside CWD/node_modules, so the
599
+ // probes above all miss and the version falls back to the hard-coded default.
600
+ // Derive the global node_modules dir from the running node binary (no npm spawn —
601
+ // statusline renders often). Covers nvm/mise (bin/../lib/node_modules) and Windows
602
+ // (bin/node_modules) layouts.
603
+ try {
604
+ const binDir = path.dirname(process.execPath);
605
+ const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
606
+ // #2221 follow-up: a custom npm prefix (e.g. ~/.npm-global) is decoupled from
607
+ // the node binary location, so the binDir-derived probes above all miss. Also
608
+ // probe the npm prefix from the environment and the common ~/.npm-global default.
609
+ for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
610
+ if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
611
+ }
612
+ for (const gm of globalModuleDirs) {
613
+ pkgPaths.push(
614
+ path.join(gm, 'ruflo', 'package.json'),
615
+ path.join(gm, '@claude-flow', 'cli', 'package.json'),
616
+ );
617
+ }
618
+ } catch { /* ignore */ }
619
+ for (const p of pkgPaths) {
620
+ if (!fs.existsSync(p)) continue;
621
+ try {
622
+ const pkg = JSON.parse(fs.readFileSync(p, 'utf-8'));
623
+ if (pkg && typeof pkg.version === 'string' && pkg.version.length > 0) { ver = pkg.version; break; }
624
+ } catch { /* ignore */ }
403
625
  }
404
- } catch (e) {
405
- // Ignore - default to 0
406
- }
407
-
408
- return {
409
- memoryMB,
410
- contextPct,
411
- intelligencePct,
412
- subAgents,
413
- };
626
+ } catch { /* ignore */ }
627
+ return ver;
414
628
  }
415
629
 
416
- // Generate progress bar
630
+ // ─── Rendering ──────────────────────────────────────────────────
631
+
417
632
  function progressBar(current, total) {
418
633
  const width = 5;
419
634
  const filled = Math.round((current / total) * width);
420
- const empty = width - filled;
421
- return '[' + '\u25CF'.repeat(filled) + '\u25CB'.repeat(empty) + ']';
635
+ return '[' + '●'.repeat(filled) + '○'.repeat(width - filled) + ']';
422
636
  }
423
637
 
424
- // Generate full statusline
425
638
  function generateStatusline() {
426
- const user = getUserInfo();
427
- const progress = getV3Progress();
428
- const security = getSecurityStatus();
429
- const swarm = getSwarmStatus();
430
- const system = getSystemMetrics();
639
+ const d = getStatuslineData();
640
+ const git = getGitInfo();
641
+ const modelName = getModelFromStdin() || (d.user && d.user.modelName) || 'Claude Code';
642
+ const ctxInfo = getContextFromStdin();
643
+ const costInfo = getCostFromStdin();
644
+ // Named RUFLO_VERSION (not pkgVersion) so the #1951 regression guard
645
+ // (scripts/audit-fix-invariants.mjs) can pin its presence in the emitted
646
+ // .cjs artifact — without it the header silently reverts to a hard-coded
647
+ // "RuFlo V3.5" for anyone whose install doesn't match the first probe path.
648
+ const RUFLO_VERSION = getPkgVersion();
649
+
650
+ const progress = d.v3Progress || {};
651
+ const security = d.security || {};
652
+ const swarm = d.swarm || {};
653
+ const system = d.system || {};
654
+ const adrs = d.adrs || {};
655
+ const hooks = d.hooks || {};
656
+ const agentdb = d.agentdb || {};
657
+ const tests = d.tests || {};
658
+
659
+ const domainsCompleted = progress.domainsCompleted || 0;
660
+ const totalDomains = progress.totalDomains || 5;
661
+ const dddProgress = progress.dddProgress || 0;
662
+ const patternsLearned = progress.patternsLearned || 0;
663
+ const activeAgents = swarm.activeAgents || 0;
664
+ const maxAgents = swarm.maxAgents || CONFIG.maxAgents;
665
+ const coordinationActive = swarm.coordinationActive || false;
666
+ const intelligencePct = system.intelligencePct || 0;
667
+ const memoryMB = system.memoryMB || 0;
668
+ const subAgents = system.subAgents || 0;
669
+ const cvesFixed = security.cvesFixed || 0;
670
+ const totalCves = security.totalCves || 0;
671
+ const secStatus = security.status || 'NONE';
672
+ const adrCount = adrs.count || 0;
673
+ const adrImpl = adrs.implemented || 0;
674
+ const hooksEnabled = hooks.enabled || 0;
675
+ const hooksTotal = hooks.total || 0;
676
+ const vectorCount = agentdb.vectorCount || 0;
677
+ const hasHnsw = agentdb.hasHnsw || false;
678
+ const dbSizeKB = agentdb.dbSizeKB || 0;
679
+ const testFiles = tests.testFiles || 0;
680
+ const testCases = tests.testCases || testFiles * 4;
681
+
431
682
  const lines = [];
432
683
 
433
- // Header Line
434
- let header = `${c.bold}${c.brightPurple}▊ RuFlo V${RUFLO_VERSION} ${c.reset}`;
435
- header += `${swarm.coordinationActive ? c.brightCyan : c.dim}● ${c.brightCyan}${user.name}${c.reset}`;
436
- if (user.gitBranch) {
437
- header += ` ${c.dim}│${c.reset} ${c.brightBlue}⎇ ${user.gitBranch}${c.reset}`;
684
+ // 3-line design (fits Claude Code's visible statusline area — line 4+ gets
685
+ // replaced by the system guidance / input prompt line):
686
+ // Line 1 Header (RuFlo version · git · model · timing · context · cost)
687
+ // Line 2 — Compressed ops (Swarm · Hooks · 🧠 · 💾 · Health)
688
+ // Line 3 Promo / disclosure row (funnel surface, ADR-301)
689
+
690
+ // ─── Line 1: header ────────────────────────────────────────────
691
+ let header = c.bold + c.brightPurple + '▊ RuFlo V' + RUFLO_VERSION + ' ' + c.reset;
692
+ header += (coordinationActive ? c.brightCyan : c.dim) + '● ' + c.brightCyan + git.name + c.reset;
693
+ if (git.gitBranch) {
694
+ header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightBlue + '⏇ ' + git.gitBranch + c.reset;
695
+ const changes = git.modified + git.staged + git.untracked;
696
+ if (changes > 0) {
697
+ let ind = '';
698
+ if (git.staged > 0) ind += c.brightGreen + '+' + git.staged + c.reset;
699
+ if (git.modified > 0) ind += c.brightYellow + '~' + git.modified + c.reset;
700
+ if (git.untracked > 0) ind += c.dim + '?' + git.untracked + c.reset;
701
+ header += ' ' + ind;
702
+ }
703
+ if (git.ahead > 0) header += ' ' + c.brightGreen + '↑' + git.ahead + c.reset;
704
+ if (git.behind > 0) header += ' ' + c.brightRed + '↓' + git.behind + c.reset;
705
+ }
706
+ header += ' ' + c.dim + '│' + c.reset + ' ' + c.purple + modelName + c.reset;
707
+ const duration = costInfo ? costInfo.duration : '';
708
+ if (duration) header += ' ' + c.dim + '│' + c.reset + ' ' + c.cyan + '⏱ ' + duration + c.reset;
709
+ if (ctxInfo && ctxInfo.usedPct > 0) {
710
+ const ctxColor = ctxInfo.usedPct >= 90 ? c.brightRed : ctxInfo.usedPct >= 70 ? c.brightYellow : c.brightGreen;
711
+ header += ' ' + c.dim + '│' + c.reset + ' ' + ctxColor + '● ' + ctxInfo.usedPct + '% ctx' + c.reset;
712
+ }
713
+ if (!CONFIG.hideCost && costInfo && costInfo.costUsd > 0) {
714
+ header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightYellow + CONFIG.costSymbol + costInfo.costUsd.toFixed(2) + c.reset;
438
715
  }
439
- header += ` ${c.dim}│${c.reset} ${c.purple}${user.modelName}${c.reset}`;
440
716
  lines.push(header);
441
717
 
442
- // Separator
443
- lines.push(`${c.dim}─────────────────────────────────────────────────────${c.reset}`);
444
-
445
- // Line 1: DDD Domain Progress
446
- const domainsColor = progress.domainsCompleted >= 3 ? c.brightGreen : progress.domainsCompleted > 0 ? c.yellow : c.red;
447
- lines.push(
448
- `${c.brightCyan}🏗️ DDD Domains${c.reset} ${progressBar(progress.domainsCompleted, progress.totalDomains)} ` +
449
- `${domainsColor}${progress.domainsCompleted}${c.reset}/${c.brightWhite}${progress.totalDomains}${c.reset} ` +
450
- `${c.brightYellow}⚡ 1.0x${c.reset} ${c.dim}→${c.reset} ${c.brightYellow}2.49x-7.47x${c.reset}`
451
- );
452
-
453
- // Line 2: Swarm + CVE + Memory + Context + Intelligence
454
- const swarmIndicator = swarm.coordinationActive ? `${c.brightGreen}◉${c.reset}` : `${c.dim}○${c.reset}`;
455
- const agentsColor = swarm.activeAgents > 0 ? c.brightGreen : c.red;
456
- let securityIcon = security.status === 'CLEAN' ? '🟢' : security.status === 'IN_PROGRESS' ? '🟡' : '🔴';
457
- let securityColor = security.status === 'CLEAN' ? c.brightGreen : security.status === 'IN_PROGRESS' ? c.brightYellow : c.brightRed;
458
-
459
- lines.push(
460
- `${c.brightYellow}🤖 Swarm${c.reset} ${swarmIndicator} [${agentsColor}${String(swarm.activeAgents).padStart(2)}${c.reset}/${c.brightWhite}${swarm.maxAgents}${c.reset}] ` +
461
- `${c.brightPurple}👥 ${system.subAgents}${c.reset} ` +
462
- `${securityIcon} ${securityColor}CVE ${security.cvesFixed}${c.reset}/${c.brightWhite}${security.totalCves}${c.reset} ` +
463
- `${c.brightCyan}💾 ${system.memoryMB}MB${c.reset} ` +
464
- `${c.brightGreen}📂 ${String(system.contextPct).padStart(3)}%${c.reset} ` +
465
- `${c.dim}🧠 ${String(system.intelligencePct).padStart(3)}%${c.reset}`
466
- );
467
-
468
- // Line 3: Architecture status
469
- const dddColor = progress.dddProgress >= 50 ? c.brightGreen : progress.dddProgress > 0 ? c.yellow : c.red;
470
- lines.push(
471
- `${c.brightPurple}🔧 Architecture${c.reset} ` +
472
- `${c.cyan}DDD${c.reset} ${dddColor}●${String(progress.dddProgress).padStart(3)}%${c.reset} ${c.dim}│${c.reset} ` +
473
- `${c.cyan}Security${c.reset} ${securityColor}●${security.status}${c.reset} ${c.dim}│${c.reset} ` +
474
- `${c.cyan}Memory${c.reset} ${c.brightGreen}●AgentDB${c.reset} ${c.dim}│${c.reset} ` +
475
- `${c.cyan}Integration${c.reset} ${swarm.coordinationActive ? c.brightCyan : c.dim}●${c.reset}`
476
- );
477
-
478
- return lines.join('\n');
479
- }
718
+ // ─── Line 2: compressed ops ────────────────────────────────────
719
+ // Everything actionable in one dense row. Show only what changes what you
720
+ // do next; diagnostic detail moves to `ruflo status --verbose`.
721
+ const agentsColor = activeAgents > 0 ? c.brightGreen : c.dim;
722
+ const hooksColor = hooksEnabled > 0 ? c.brightGreen : c.dim;
723
+ const intellColor = intelligencePct >= 80 ? c.brightGreen : intelligencePct >= 40 ? c.brightYellow : c.dim;
724
+ const swarmInd = coordinationActive ? c.brightGreen + '◉' + c.reset + ' ' : c.dim + '○' + c.reset + ' ';
725
+ const cvesClean = totalCves === 0 || cvesFixed === totalCves;
726
+ const healthAllGreen = (secStatus === 'CLEAN' || secStatus === 'NONE') && cvesClean;
727
+ const opsParts = [];
728
+ opsParts.push(c.cyan + 'Swarm ' + swarmInd + agentsColor + activeAgents + c.reset + '/' + c.brightWhite + maxAgents + c.reset);
729
+ if (subAgents > 0) opsParts.push(c.brightPurple + '👥 ' + subAgents + c.reset);
730
+ opsParts.push(c.cyan + 'Hooks ' + hooksColor + hooksEnabled + c.reset + '/' + c.brightWhite + hooksTotal + c.reset);
731
+ opsParts.push(intellColor + '🧠 ' + intelligencePct + '%' + c.reset);
732
+ opsParts.push(c.brightCyan + '💾 ' + memoryMB + 'MB' + c.reset);
733
+ // Health: one glyph when green, terse copy when there's something to act on.
734
+ if (healthAllGreen) {
735
+ opsParts.push(c.brightGreen + '🛡 ✓' + c.reset);
736
+ } else {
737
+ if (secStatus === 'PENDING') opsParts.push(c.brightYellow + '🛡 scan pending' + c.reset);
738
+ else if (secStatus === 'IN_PROGRESS') opsParts.push(c.brightYellow + '🛡 scanning…' + c.reset);
739
+ else if (secStatus === 'STALE') opsParts.push(c.brightYellow + '🛡 scan stale' + c.reset);
740
+ else if (secStatus !== 'NONE' && secStatus !== 'CLEAN') opsParts.push(c.brightRed + '🛡 ' + secStatus.toLowerCase() + c.reset);
741
+ if (totalCves > 0 && cvesFixed < totalCves) {
742
+ const unfixed = totalCves - cvesFixed;
743
+ opsParts.push(c.brightRed + '⚠ ' + unfixed + ' CVE' + (unfixed === 1 ? '' : 's') + c.reset);
744
+ }
745
+ }
746
+ lines.push(opsParts.join(' ' + c.dim + '·' + c.reset + ' '));
747
+
748
+ // ─── Line 3: promo / disclosure / insight ───────────────────────
749
+ // Colored by content kind so it reads as *what it is*, not as noise:
750
+ // disclosure brightCyan (announcement / capability link)
751
+ // promotional brightPurple (Cognitum sponsor spot)
752
+ // educational → yellow (a tip)
753
+ // insight → brightRed (environment/task-aware, local, actionable —
754
+ // distinct from remote content on purpose)
755
+ const promoRow = getPromoRow(d);
756
+ if (promoRow) {
757
+ const kind = (d && d.promo && d.promo.kind) || 'disclosure';
758
+ const promoColor = kind === 'promotional' ? c.brightPurple
759
+ : kind === 'educational' ? c.yellow
760
+ : kind === 'insight' ? c.brightRed
761
+ : c.brightCyan;
762
+ lines.push(promoColor + promoRow + c.reset);
763
+ }
480
764
 
481
- // Generate JSON data
482
- function generateJSON() {
483
- return {
484
- user: getUserInfo(),
485
- v3Progress: getV3Progress(),
486
- security: getSecurityStatus(),
487
- swarm: getSwarmStatus(),
488
- system: getSystemMetrics(),
489
- performance: {
490
- flashAttentionTarget: '2.49x-7.47x',
491
- searchImprovement: '150x-12,500x',
492
- memoryReduction: '50-75%',
493
- },
494
- lastUpdated: new Date().toISOString(),
495
- };
765
+ // Trailing blank line so Claude Code's input prompt gets breathing room
766
+ // instead of butting directly against the last statusline row.
767
+ return lines.join('\n') + '\n';
496
768
  }
497
769
 
498
- /**
499
- * Generate single-line output for Claude Code compatibility
500
- * This avoids the collision zone issue entirely by using one line
501
- * @see https://github.com/ruvnet/claude-flow/issues/985
502
- */
503
- function generateSingleLine() {
504
- if (!CONFIG.enabled) return '';
505
-
506
- const user = getUserInfo();
507
- const progress = getV3Progress();
508
- const security = getSecurityStatus();
509
- const swarm = getSwarmStatus();
510
- const system = getSystemMetrics();
511
-
512
- const swarmIndicator = swarm.coordinationActive ? '●' : '';
513
- const securityStatus = security.status === 'CLEAN' ? '✓' :
514
- security.cvesFixed > 0 ? '~' : '✗';
515
-
516
- return `${c.brightPurple}RuFlo${c.reset} ${c.dim}|${c.reset} ` +
517
- `${c.cyan}D:${progress.domainsCompleted}/${progress.totalDomains}${c.reset} ${c.dim}|${c.reset} ` +
518
- `${c.yellow}S:${swarmIndicator}${swarm.activeAgents}/${swarm.maxAgents}${c.reset} ${c.dim}|${c.reset} ` +
519
- `${security.status === 'CLEAN' ? c.green : c.red}CVE:${securityStatus}${security.cvesFixed}/${security.totalCves}${c.reset} ${c.dim}|${c.reset} ` +
520
- `${c.dim}🧠${system.intelligencePct}%${c.reset}`;
770
+ // ─── Funnel promo row (ADR-301) ─────────────────────────────────
771
+ // Allowlist for OSC 8 hyperlink targets. Ships in code (not in payload) so
772
+ // no message can smuggle a link to an unapproved host.
773
+ //
774
+ // The final destination hosts (cognitum.one / agentics.org) AND the
775
+ // click-redirect host are both allowlisted here: promo.ts routes every
776
+ // clickable message through the server-side click-redirect (ADR-311 §7)
777
+ // so promo_open + geo are captured before the 302 to the real target —
778
+ // so the OSC 8 link the renderer emits points at the redirect host, not
779
+ // the final destination directly.
780
+ const PROMO_LINK_HOSTS = new Set([
781
+ 'cognitum.one', 'www.cognitum.one', 'docs.cognitum.one',
782
+ // agentics.org OSS foundation, distinct sponsor domain. Kept in sync
783
+ // with messages.ts ALLOWED_URL_HOSTS.
784
+ 'agentics.org', 'www.agentics.org',
785
+ // Click-redirect host (funnel.ruv.io once its TLS cert is live; the raw
786
+ // Cloud Run hostname is allowlisted too since event-transport.ts /
787
+ // message-transport.ts / attribution.ts currently point at it as a TEMP
788
+ // fallback while the domain mapping's cert provisions).
789
+ 'funnel.ruv.io',
790
+ 'cognitum-analytics-63rzcdswba-uc.a.run.app',
791
+ ]);
792
+
793
+ // Emit OSC 8 hyperlinks unless the environment is known-broken. tmux mangles
794
+ // raw OSC 8 (see anthropics/claude-code#27047) — opt in via env if wrapped.
795
+ function terminalSupportsHyperlinks() {
796
+ if (process.env.CI || process.env.GITHUB_ACTIONS) return false;
797
+ if (process.env.TERM === 'dumb') return false;
798
+ if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_STATUSLINE_HYPERLINKS || ''))) return false;
799
+ if (process.env.TMUX && !process.env.RUFLO_STATUSLINE_HYPERLINKS_TMUX) return false;
800
+ return true;
521
801
  }
522
802
 
523
- /**
524
- * Generate safe multi-line statusline that avoids Claude Code collision zone
525
- * The collision zone is columns 15-25 on the second-to-last line.
526
- * We pad that line with spaces to push content past column 25.
527
- * @see https://github.com/ruvnet/claude-flow/issues/985
528
- */
529
- function generateSafeStatusline() {
530
- if (!CONFIG.enabled) return '';
531
-
532
- const user = getUserInfo();
533
- const progress = getV3Progress();
534
- const security = getSecurityStatus();
535
- const swarm = getSwarmStatus();
536
- const system = getSystemMetrics();
537
- const lines = [];
803
+ // Wrap a label in an OSC 8 hyperlink escape sequence. Falls back to the raw
804
+ // label whenever the URL is not an allowlisted https target, when the terminal
805
+ // can't render hyperlinks, or when parsing fails a broken link must never
806
+ // leave a raw URL or stray escape in the statusline output.
807
+ function safeTerminalLink(label, url) {
808
+ if (!terminalSupportsHyperlinks()) return label;
809
+ if (typeof url !== 'string' || url.length === 0) return label;
810
+ let parsed;
811
+ try { parsed = new URL(url); } catch { return label; }
812
+ if (parsed.protocol !== 'https:') return label;
813
+ if (!PROMO_LINK_HOSTS.has(parsed.hostname)) return label;
814
+ const cleanLabel = String(label).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '');
815
+ if (cleanLabel.length === 0) return label;
816
+ const ESC = '\u001b';
817
+ return ESC + ']8;;' + parsed.href + ESC + '\\' + cleanLabel + ESC + ']8;;' + ESC + '\\';
818
+ }
538
819
 
539
- // Header Line
540
- let header = `${c.bold}${c.brightPurple}▊ RuFlo V${RUFLO_VERSION} ${c.reset}`;
541
- header += `${swarm.coordinationActive ? c.brightCyan : c.dim}● ${c.brightCyan}${user.name}${c.reset}`;
542
- if (user.gitBranch) {
543
- header += ` ${c.dim}│${c.reset} ${c.brightBlue}⎇ ${user.gitBranch}${c.reset}`;
820
+ function getPromoRow(d) {
821
+ try {
822
+ if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
823
+ if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_FUNNEL || ''))) return null;
824
+ const promo = d && d.promo;
825
+ if (!promo || typeof promo.text !== 'string') return null;
826
+ // Strip control chars / ANSI / bidi overrides and hard-cap length —
827
+ // promo copy is data and must never emit its own terminal sequences.
828
+ const text = promo.text
829
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
830
+ .slice(0, 100)
831
+ .trim();
832
+ if (text.length === 0) return null;
833
+ // Split the label from the trailing "· manage: ruflo settings" instruction
834
+ // so each part gets styling that matches what it actually IS:
835
+ // 1. label — OSC 8 hyperlink + underline. A real clickable link.
836
+ // 2. "manage:" — dim. Just a connector word, no action implied.
837
+ // 3. "ruflo settings" — bold/bright, NOT underlined. This is a shell
838
+ // command the user TYPES, not a link they CLICK — a terminal can
839
+ // never safely execute a command from a click (that would let any
840
+ // server-served message run arbitrary commands), so we deliberately
841
+ // avoid the underline/OSC8 cues that imply "clickable". Bold+bright
842
+ // instead signals "this is the important bit — copy/type it".
843
+ // Educational tips have no manage tail and no URL — plain text through.
844
+ const manageIdx = text.indexOf(' · manage: ');
845
+ const label = manageIdx > 0 ? text.slice(0, manageIdx) : text;
846
+ const manageWord = manageIdx > 0 ? ' · manage: ' : '';
847
+ const command = manageIdx > 0 ? text.slice(manageIdx + manageWord.length) : '';
848
+ const UL_ON = '\u001b[4m';
849
+ const UL_OFF = '\u001b[24m';
850
+ const DIM_ON = '\u001b[2m';
851
+ const DIM_OFF = '\u001b[22m';
852
+ const BOLD_ON = '\u001b[1m';
853
+ const BOLD_OFF = '\u001b[22m';
854
+ const linked = promo.url ? UL_ON + safeTerminalLink(label, promo.url) + UL_OFF : label;
855
+ if (!command) return linked;
856
+ return linked + DIM_ON + manageWord + DIM_OFF + BOLD_ON + command + BOLD_OFF;
857
+ } catch (e) {
858
+ return null; // the promo row must never break the statusline
544
859
  }
545
- header += ` ${c.dim}│${c.reset} ${c.purple}${user.modelName}${c.reset}`;
546
- lines.push(header);
860
+ }
547
861
 
548
- // Separator
549
- lines.push(`${c.dim}─────────────────────────────────────────────────────${c.reset}`);
550
-
551
- // Line 1: DDD Domain Progress
552
- const domainsColor = progress.domainsCompleted >= 3 ? c.brightGreen : progress.domainsCompleted > 0 ? c.yellow : c.red;
553
- lines.push(
554
- `${c.brightCyan}🏗️ DDD Domains${c.reset} ${progressBar(progress.domainsCompleted, progress.totalDomains)} ` +
555
- `${domainsColor}${progress.domainsCompleted}${c.reset}/${c.brightWhite}${progress.totalDomains}${c.reset} ` +
556
- `${c.brightYellow}⚡ 1.0x${c.reset} ${c.dim}→${c.reset} ${c.brightYellow}2.49x-7.47x${c.reset}`
557
- );
558
-
559
- // Line 2 (COLLISION LINE): Swarm status with padding after label
560
- // The emoji+label is ~10 columns. Padding pushes content past collision zone (cols 15-25)
561
- const swarmIndicator = swarm.coordinationActive ? `${c.brightGreen}◉${c.reset}` : `${c.dim}○${c.reset}`;
562
- const agentsColor = swarm.activeAgents > 0 ? c.brightGreen : c.red;
563
- let securityIcon = security.status === 'CLEAN' ? '🟢' : security.status === 'IN_PROGRESS' ? '🟡' : '🔴';
564
- let securityColor = security.status === 'CLEAN' ? c.brightGreen : security.status === 'IN_PROGRESS' ? c.brightYellow : c.brightRed;
565
-
566
- // Padding after "🤖 Swarm" (emoji 2 cols + " Swarm" 6 cols = 8, pad 18 to reach col 26)
567
- lines.push(
568
- `${c.brightYellow}🤖 Swarm${c.reset} ` + // 18 spaces padding
569
- `${swarmIndicator} [${agentsColor}${String(swarm.activeAgents).padStart(2)}${c.reset}/${c.brightWhite}${swarm.maxAgents}${c.reset}] ` +
570
- `${c.brightPurple}👥 ${system.subAgents}${c.reset} ` +
571
- `${securityIcon} ${securityColor}CVE ${security.cvesFixed}${c.reset}/${c.brightWhite}${security.totalCves}${c.reset} ` +
572
- `${c.brightCyan}💾 ${system.memoryMB}MB${c.reset} ` +
573
- `${c.dim}🧠 ${system.intelligencePct}%${c.reset}`
574
- );
575
-
576
- // Line 3: Architecture status (this is the last line, not in collision zone)
577
- const dddColor = progress.dddProgress >= 50 ? c.brightGreen : progress.dddProgress > 0 ? c.yellow : c.red;
578
- lines.push(
579
- `${c.brightPurple}🔧 Architecture${c.reset} ` +
580
- `${c.cyan}DDD${c.reset} ${dddColor}●${String(progress.dddProgress).padStart(3)}%${c.reset} ${c.dim}│${c.reset} ` +
581
- `${c.cyan}Security${c.reset} ${securityColor}●${security.status}${c.reset} ${c.dim}│${c.reset} ` +
582
- `${c.cyan}Memory${c.reset} ${c.brightGreen}●AgentDB${c.reset} ${c.dim}│${c.reset} ` +
583
- `${c.cyan}Integration${c.reset} ${swarm.coordinationActive ? c.brightCyan : c.dim}●${c.reset}`
584
- );
585
-
586
- return lines.join('\n');
862
+ // JSON output — delegates to CLI for accuracy; caller can use --json flag
863
+ function generateJSON() {
864
+ const d = getStatuslineData();
865
+ const git = getGitInfo();
866
+ return Object.assign({}, d, {
867
+ user: Object.assign({ name: git.name, gitBranch: git.gitBranch }, d.user || {}),
868
+ git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
869
+ lastUpdated: new Date().toISOString(),
870
+ });
587
871
  }
588
872
 
589
- // Main
873
+ // ─── Main ───────────────────────────────────────────────────────
590
874
  if (process.argv.includes('--json')) {
591
875
  console.log(JSON.stringify(generateJSON(), null, 2));
592
876
  } else if (process.argv.includes('--compact')) {
593
877
  console.log(JSON.stringify(generateJSON()));
594
- } else if (process.argv.includes('--single')) {
595
- // Single-line mode - completely avoids collision zone
596
- console.log(generateSingleLine());
597
- } else if (process.argv.includes('--unsafe') || process.argv.includes('--legacy')) {
598
- // Legacy mode - original multi-line without collision avoidance
599
- console.log(generateStatusline());
600
878
  } else {
601
- // Default: Safe multi-line mode with collision zone avoidance
602
- // Use --unsafe or --legacy to get the original behavior
603
- console.log(generateSafeStatusline());
879
+ console.log(generateStatusline());
604
880
  }