claude-flow 3.56.3 → 3.56.5

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 (77) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +13 -8
  2. package/.claude/helpers/hook-handler.cjs +19 -14
  3. package/.claude/helpers/learning-service.mjs +5 -2
  4. package/.claude/helpers/session.cjs +56 -10
  5. package/.claude/helpers/statusline.cjs +1338 -1328
  6. package/.claude-plugin/scripts/ruflo-hook.cjs +14 -2
  7. package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
  8. package/node_modules/@claude-flow/mcp/dist/resource-registry.d.ts +1 -0
  9. package/node_modules/@claude-flow/mcp/dist/resource-registry.d.ts.map +1 -1
  10. package/node_modules/@claude-flow/mcp/dist/resource-registry.js +10 -4
  11. package/node_modules/@claude-flow/mcp/dist/resource-registry.js.map +1 -1
  12. package/node_modules/@claude-flow/mcp/dist/sampling.d.ts.map +1 -1
  13. package/node_modules/@claude-flow/mcp/dist/sampling.js +13 -6
  14. package/node_modules/@claude-flow/mcp/dist/sampling.js.map +1 -1
  15. package/node_modules/@claude-flow/mcp/dist/task-manager.d.ts.map +1 -1
  16. package/node_modules/@claude-flow/mcp/dist/task-manager.js +10 -1
  17. package/node_modules/@claude-flow/mcp/dist/task-manager.js.map +1 -1
  18. package/node_modules/@claude-flow/mcp/dist/tool-registry.d.ts +1 -0
  19. package/node_modules/@claude-flow/mcp/dist/tool-registry.d.ts.map +1 -1
  20. package/node_modules/@claude-flow/mcp/dist/tool-registry.js +17 -11
  21. package/node_modules/@claude-flow/mcp/dist/tool-registry.js.map +1 -1
  22. package/node_modules/@claude-flow/mcp/package.json +1 -1
  23. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  24. package/node_modules/@claude-flow/security/dist/index.d.ts +1 -1
  25. package/node_modules/@claude-flow/security/dist/index.d.ts.map +1 -1
  26. package/node_modules/@claude-flow/security/dist/index.js +1 -1
  27. package/node_modules/@claude-flow/security/dist/index.js.map +1 -1
  28. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.d.ts +9 -1
  29. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.d.ts.map +1 -1
  30. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.js +15 -2
  31. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.js.map +1 -1
  32. package/node_modules/@claude-flow/security/package.json +1 -1
  33. package/package.json +3 -3
  34. package/v3/@claude-flow/cli/README.md +62 -158
  35. package/v3/@claude-flow/cli/catalog-manifest.json +5 -5
  36. package/v3/@claude-flow/cli/dist/src/commands/doctor.js +7 -13
  37. package/v3/@claude-flow/cli/dist/src/commands/hooks.js +3 -6
  38. package/v3/@claude-flow/cli/dist/src/commands/init.js +3 -10
  39. package/v3/@claude-flow/cli/dist/src/commands/metaharness.js +9 -14
  40. package/v3/@claude-flow/cli/dist/src/commands/plugins.js +81 -8
  41. package/v3/@claude-flow/cli/dist/src/commands/security-code-patterns.d.ts +40 -0
  42. package/v3/@claude-flow/cli/dist/src/commands/security-code-patterns.js +192 -0
  43. package/v3/@claude-flow/cli/dist/src/commands/security.js +8 -11
  44. package/v3/@claude-flow/cli/dist/src/commands/status.js +8 -10
  45. package/v3/@claude-flow/cli/dist/src/init/executor.d.ts +1 -1
  46. package/v3/@claude-flow/cli/dist/src/init/executor.js +25 -16
  47. package/v3/@claude-flow/cli/dist/src/init/helper-commands.d.ts +18 -0
  48. package/v3/@claude-flow/cli/dist/src/init/helper-commands.js +142 -0
  49. package/v3/@claude-flow/cli/dist/src/init/helpers-generator.js +128 -29
  50. package/v3/@claude-flow/cli/dist/src/init/settings-generator.js +36 -103
  51. package/v3/@claude-flow/cli/dist/src/mcp-tools/metaharness-tools.js +7 -10
  52. package/v3/@claude-flow/cli/dist/src/mcp-tools/task-tools.js +24 -3
  53. package/v3/@claude-flow/cli/dist/src/mcp-tools/testgen-tools.js +8 -12
  54. package/v3/@claude-flow/cli/dist/src/mcp-tools/transfer-tools.js +9 -8
  55. package/v3/@claude-flow/cli/dist/src/mcp-tools/workflow-tools.js +10 -4
  56. package/v3/@claude-flow/cli/dist/src/memory/graph-edge-writer.js +33 -11
  57. package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +2 -19
  58. package/v3/@claude-flow/cli/dist/src/memory/shared-sqlite.d.ts +6 -0
  59. package/v3/@claude-flow/cli/dist/src/memory/shared-sqlite.js +35 -7
  60. package/v3/@claude-flow/cli/dist/src/missions/schemas.d.ts +125 -125
  61. package/v3/@claude-flow/cli/dist/src/mods/command-registry/schema.d.ts +199 -199
  62. package/v3/@claude-flow/cli/dist/src/plugins/manager.d.ts +3 -1
  63. package/v3/@claude-flow/cli/dist/src/plugins/manager.js +31 -10
  64. package/v3/@claude-flow/cli/dist/src/plugins/own-install.d.ts +15 -0
  65. package/v3/@claude-flow/cli/dist/src/plugins/own-install.js +51 -0
  66. package/v3/@claude-flow/cli/dist/src/plugins/store/discovery.d.ts +26 -2
  67. package/v3/@claude-flow/cli/dist/src/plugins/store/discovery.js +108 -81
  68. package/v3/@claude-flow/cli/dist/src/plugins/store/index.d.ts +1 -1
  69. package/v3/@claude-flow/cli/dist/src/plugins/store/index.js +1 -1
  70. package/v3/@claude-flow/cli/dist/src/plugins/store/types.d.ts +5 -0
  71. package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.d.ts +13 -0
  72. package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.js +29 -0
  73. package/v3/@claude-flow/cli/dist/src/ruvector/graph-analyzer.js +18 -6
  74. package/v3/@claude-flow/cli/dist/src/transfer/ipfs/client.js +3 -1
  75. package/v3/@claude-flow/cli/package.json +5 -5
  76. package/.claude/.proven-config-version +0 -1
  77. package/.claude/proven-config.json +0 -42
@@ -1,1328 +1,1338 @@
1
- #!/usr/bin/env node
2
- /**
3
- * RuFlo V3 Statusline — delegation build (#2195)
4
- *
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.
8
- *
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.
12
- *
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/).
15
- *
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]
20
- */
21
-
22
- /* eslint-disable @typescript-eslint/no-var-requires */
23
- const fs = require('fs');
24
- const path = require('path');
25
- const { execSync } = require('child_process');
26
- const os = require('os');
27
-
28
- // Configuration
29
- const CONFIG = {
30
- maxAgents: 15,
31
- // Header identity defaults to project/repository name. Set `author` to
32
- // retain the previous `git config user.name` display (#2682).
33
- identityMode: (process.env.RUFLO_STATUSLINE_IDENTITY || 'project').toLowerCase(),
34
- // Session-cost display. Claude Code's cost.total_cost_usd is a client-side
35
- // estimate that "may differ from your actual bill" and reads as misleading on
36
- // subscription plans, where token usage is not billed per dollar. These let
37
- // each user pick what the segment means to them without changing the default.
38
- // RUFLO_STATUSLINE_COST_SYMBOL override the leading '$' (e.g. ⚡, €, 🌱);
39
- // set to an empty string for the number alone.
40
- // RUFLO_STATUSLINE_HIDE_COST 1/true/yes/on removes the segment entirely.
41
- costSymbol: process.env.RUFLO_STATUSLINE_COST_SYMBOL ?? '$',
42
- hideCost: /^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_HIDE_COST || ''),
43
- };
44
-
45
- const CWD = process.cwd();
46
-
47
- // ─── Delegation cache ───────────────────────────────────────────
48
- // Cache the CLI JSON result so rapid prompt re-renders (Claude Code
49
- // refreshes the statusline several times a second while streaming) don't
50
- // re-invoke the CLI each time.
51
- // #2337 bumped 10s → 60s.
52
- // Followup for anthropics/claude-code#70200 (Windows console-flash bug —
53
- // claude.exe spawns hook/statusline subprocesses without CREATE_NO_WINDOW,
54
- // producing a visible cmd flash on every render): bumped 60s → 300s to
55
- // reduce the flash rate 5x on Windows until the upstream fix ships.
56
- // Tradeoff: stat/git counters update every 5min instead of every 1min;
57
- // promo/insight row still rotates on its own tighter 20s promoFresh clock.
58
- const CACHE_FILE = path.join(os.tmpdir(), 'ruflo-statusline-cache-' + require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) + '.json');
59
- const CACHE_TTL_MS = 300000;
60
-
61
- // The promo/insight row is designed to rotate on a 20s cadence (funnel/
62
- // rotation.ts's ROTATION_SLOT_MS / funnel/promo.ts's insight-slot check —
63
- // duplicated here as a bare number since this generated script has no
64
- // runtime import of the funnel module; keep in sync if that constant ever
65
- // changes). The rotation slot is only ever (re)computed SERVER-SIDE inside
66
- // the CLI subprocess this file shells out to — so a general 60s data cache
67
- // (correct and necessary for #2337) silently made that 20s design
68
- // unreachable: cache.fresh stayed true across 2-3 whole rotation slots,
69
- // so the row visibly "didn't rotate" (user report). Fix: track promo
70
- // freshness on its OWN, tighter clock — when it lags behind the current
71
- // slot, fall through to a real CLI call even though the REST of the
72
- // cached data (security/swarm/system) is still within CACHE_TTL_MS. This
73
- // does not touch or regress #2337's fix; it only adds a narrower check.
74
- const PROMO_ROTATION_SLOT_MS = 20000;
75
-
76
- // Persistent last-known-good promo record. Lives outside the /tmp cache so it
77
- // survives a full cache wipe / cache write race / CLI failure combo. Written
78
- // every time we successfully render a promo; read as a last resort so the row
79
- // never blinks out mid-session (was: 'promo shows then hides' bug report).
80
- const PROMO_MEMO_FILE = path.join(os.homedir(), '.ruflo', 'statusline-promo.json');
81
- 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.
82
-
83
- // #2337: resolve an already-installed @claude-flow/cli (or ruflo) bin so we
84
- // can invoke it directly via `node`. The previous version called
85
- // `npx --yes @claude-flow/cli@latest` on every uncached render, which forces
86
- // a registry resolution + cold-start of the entire CLI per render. With
87
- // multiple concurrent Claude Code sessions this storms the host (reporter
88
- // saw load average 40-65 on a 12-core box).
89
- //
90
- // Returns EVERY existing bin/cli.js candidate, in preference order (project,
91
- // monorepo, plugin marketplace, global node_modules including custom-prefix
92
- // layouts like ~/.npm-global) — mirrors getPkgVersion()'s own path probing.
93
- //
94
- // Returns a list, not a single winner: `fs.existsSync` only proves a file is
95
- // present, not that it actually runs. A marketplace/npx-cached install can
96
- // exist on disk but be broken (observed in practice: a stale marketplace
97
- // checkout whose dist/ imports a workspace package, '@claude-flow/cli-core',
98
- // that isn't bundled there — every invocation throws ERR_MODULE_NOT_FOUND).
99
- // Picking the first EXISTING path and never falling through meant a single
100
- // broken install silently killed the promo row for the entire session (the
101
- // CLI call always failed, so the memo could never refresh and eventually
102
- // expired). getStatuslineData() now walks this whole list and tries the next
103
- // candidate on failure, so one broken install can't permanently wedge it.
104
- function resolveCliBinCandidates() {
105
- const candidates = [];
106
- try {
107
- const home = os.homedir();
108
- candidates.push(
109
- path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
110
- path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
111
- path.join(CWD, 'node_modules', 'ruflo', 'bin', 'cli.js'),
112
- path.join(CWD, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
113
- );
114
- try {
115
- const binDir = path.dirname(process.execPath);
116
- const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
117
- for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
118
- if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
119
- }
120
- for (const gm of globalModuleDirs) {
121
- candidates.push(
122
- path.join(gm, 'ruflo', 'bin', 'cli.js'),
123
- path.join(gm, '@claude-flow', 'cli', 'bin', 'cli.js'),
124
- );
125
- }
126
- } catch { /* ignore */ }
127
- } catch { /* ignore */ }
128
- return candidates.filter((p) => {
129
- try {
130
- if (!fs.existsSync(p)) return false;
131
- // A candidate's bin/cli.js can exist on disk while its compiled
132
- // dist/ never got built (Claude Code's own plugin marketplace just
133
- // git-clones the repo — no install/build step — so every marketplace
134
- // install is a source-only checkout by construction). Importing
135
- // dist/src/index.js from bin/cli.js then throws MODULE_NOT_FOUND on
136
- // every real command; only --version happens to survive it. Check
137
- // for the compiled entrypoint too so a doomed candidate is skipped
138
- // up front instead of wasting a spawn-and-fail on every render.
139
- return fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'));
140
- } catch { return false; }
141
- });
142
- }
143
-
144
- // Return { fresh, promoFresh, data }. 'fresh' is true only if within the TTL
145
- // — but data is returned regardless (stale-while-revalidate). This lets us
146
- // serve last known state (specifically the promo row) when the CLI is
147
- // slow/unavailable, so users don't see the funnel row flicker in and out on
148
- // cache expiry. 'promoFresh' is a SEPARATE, tighter check on the same clock
149
- // as PROMO_ROTATION_SLOT_MS — see that constant's comment for why the promo
150
- // row needs its own freshness bound distinct from the general 60s TTL.
151
- function readCache() {
152
- try {
153
- if (fs.existsSync(CACHE_FILE)) {
154
- const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
155
- if (raw && raw._ts && raw.data) {
156
- const age = Date.now() - raw._ts;
157
- return { fresh: age < CACHE_TTL_MS, promoFresh: age < PROMO_ROTATION_SLOT_MS, data: raw.data };
158
- }
159
- }
160
- } catch { /* ignore */ }
161
- return { fresh: false, promoFresh: false, data: null };
162
- }
163
-
164
- function writeCache(data) {
165
- try { fs.writeFileSync(CACHE_FILE, JSON.stringify({ _ts: Date.now(), data }), 'utf-8'); } catch { /* ignore */ }
166
- // Also memoize any promo we saw so the row can survive future CLI hiccups.
167
- try {
168
- if (data && data.promo && typeof data.promo === 'object') {
169
- fs.mkdirSync(path.dirname(PROMO_MEMO_FILE), { recursive: true, mode: 0o700 });
170
- fs.writeFileSync(PROMO_MEMO_FILE, JSON.stringify({ _ts: Date.now(), promo: data.promo }), { encoding: 'utf-8', mode: 0o600 });
171
- }
172
- } catch { /* ignore */ }
173
- }
174
-
175
- // Last resort: read a memoized promo (up to 6h old). Used when no cache and
176
- // no CLI response is available — the row still renders, so users don't see
177
- // the disclosure blink out. Returns null when the memo is absent, expired,
178
- // or malformed. Never throws.
179
- function readPromoMemo() {
180
- try {
181
- if (!fs.existsSync(PROMO_MEMO_FILE)) return null;
182
- const raw = JSON.parse(fs.readFileSync(PROMO_MEMO_FILE, 'utf-8'));
183
- if (raw && raw._ts && (Date.now() - raw._ts) < PROMO_MEMO_TTL_MS && raw.promo) {
184
- return raw.promo;
185
- }
186
- } catch { /* ignore */ }
187
- return null;
188
- }
189
-
190
- /**
191
- * Single source of truth: delegate to the CLI hooks statusline --json command.
192
- * Falls back to a minimal static object on failure so the statusline still renders.
193
- *
194
- * Fix for ruflo#2195: the previous local readers returned 0 for AgentDB patterns
195
- * (missed the .swarm/memory.db → AgentDB path), computed dddProgress wrong,
196
- * and only counted ADRs in v3/implementation/adrs/ (missed v3/docs/adr/).
197
- */
198
- // Overlay the memoized promo onto any data object that's missing one. This is
199
- // the safety net that keeps the funnel row rendered when an OLDER cached CLI
200
- // version is picked up by npx — that older CLI succeeds but omits promo, so
201
- // the JSON round-trips clean but without our row. We patch it back here.
202
- function overlayMemoPromo(data) {
203
- if (data && !data.promo) {
204
- const memoPromo = readPromoMemo();
205
- if (memoPromo) data.promo = memoPromo;
206
- }
207
- return data;
208
- }
209
-
210
- function getStatuslineData() {
211
- const cache = readCache();
212
- // Both clocks must be satisfied to skip the CLI call entirely: the general
213
- // 60s TTL (#2337 — don't re-spawn the CLI on every rapid re-render) AND the
214
- // tighter promo-rotation clock (this fix — don't let a still-fresh 60s
215
- // cache silently freeze the promo/insight row across multiple 20s slots).
216
- if (cache.fresh && cache.promoFresh) {
217
- return applyLocalOverlays(overlayMemoPromo(cache.data));
218
- }
219
-
220
- // #2337: prefer an already-installed CLI bin via direct `node` invocation —
221
- // no npx, no registry round-trip, no @latest re-resolve per render. Try
222
- // every candidate that actually EXISTS (not just the first) before falling
223
- // back to `npx --prefer-offline @claude-flow/cli` (no @latest); an existing
224
- // but broken install (e.g. a stale marketplace checkout missing a bundled
225
- // workspace dep) must not block trying the next one.
226
- //
227
- // No `2>/dev/null` here (deliberately) — the execSync call below already
228
- // sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
229
- // Node level regardless of shell. The redirect was redundant on POSIX and
230
- // actively broke every candidate on Windows: cmd.exe (execSync's default
231
- // shell there) doesn't understand /dev/null, so the CLI delegation always
232
- // failed, silently degrading every render to buildLocalFallback() — 0%
233
- // intelligence and an empty promo row (the memo cache that keeps the row
234
- // populated across CLI hiccups is only ever written from a SUCCESSFUL
235
- // delegation, so it could never get seeded on Windows either).
236
- const cmds = resolveCliBinCandidates()
237
- .map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
238
- .concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
239
- for (const cmd of cmds) {
240
- try {
241
- const raw = execSync(
242
- cmd,
243
- { encoding: 'utf-8', timeout: 8000, stdio: ['pipe', 'pipe', 'pipe'], cwd: CWD, windowsHide: true }
244
- ).trim();
245
- // The CLI may emit preamble lines before the JSON — find the first '{'.
246
- const jsonStart = raw.indexOf('{');
247
- if (jsonStart === -1) throw new Error('no JSON in CLI output');
248
- const data = JSON.parse(raw.slice(jsonStart));
249
- // Overlay every block the CLI JSON omits (adrs/agentdb/tests/hooks/integration)
250
- // with real local reads, so those segments reflect actual state instead of 0.
251
- applyLocalOverlays(data);
252
- overlayMemoPromo(data);
253
- writeCache(data);
254
- return data;
255
- } catch { /* this candidate unavailable, broken, or timed out — try the next */ }
256
- }
257
-
258
- // Stale-while-revalidate: if we have any cached data, keep serving it so the
259
- // funnel row doesn't flicker on CLI hiccups. Overlay fresh local reads for
260
- // the segments the CLI JSON doesn't populate; the promo row survives.
261
- if (cache.data) {
262
- applyLocalOverlays(cache.data);
263
- overlayMemoPromo(cache.data);
264
- return cache.data;
265
- }
266
-
267
- // Last resort: local probes + memo. Users still see the funnel row.
268
- return overlayMemoPromo(buildLocalFallback());
269
- }
270
-
271
- // Count ADRs from BOTH known directories (fix for ruflo#2195: old code missed
272
- // v3/docs/adr/ which holds ADR-088..ADR-137, i.e. 41 of the 128 total ADRs).
273
- function getLocalADRCount() {
274
- const adrDirs = [
275
- path.join(CWD, 'v3', 'implementation', 'adrs'),
276
- path.join(CWD, 'v3', 'docs', 'adr'),
277
- path.join(CWD, 'docs', 'adrs'),
278
- path.join(CWD, '.claude-flow', 'adrs'),
279
- ];
280
- let total = 0;
281
- for (const dir of adrDirs) {
282
- try {
283
- if (fs.existsSync(dir)) {
284
- const files = fs.readdirSync(dir).filter(function(f) {
285
- return f.endsWith('.md') && (f.startsWith('ADR-') || f.startsWith('adr-') || /^\d{4}-/.test(f));
286
- });
287
- total += files.length;
288
- }
289
- } catch { /* ignore */ }
290
- }
291
- return { count: total, implemented: total, compliance: 0 };
292
- }
293
-
294
- // ─── Local overlays for segments the CLI JSON omits ──────────────
295
- // 'hooks statusline --json' only returns user/v3Progress/security/swarm/system.
296
- // agentdb/tests/hooks/integration are never populated, so without these overlays
297
- // they render as a permanent 0. Each reader is cheap and degrades to zeros.
298
-
299
- // Real AgentDB stats from the local memory DB. Vectors live in .swarm/memory.db
300
- // (sql.js + HNSW); ruvector.db is an opaque redb store counted only toward size.
301
- // One read-only sqlite3 query (mode=ro never takes a write lock the daemon owns).
302
- function getLocalAgentDB() {
303
- const result = { vectorCount: 0, dbSizeKB: 0, hasHnsw: false };
304
- try {
305
- let bytes = 0;
306
- for (const f of ['.swarm/memory.db', 'ruvector.db']) {
307
- try { bytes += fs.statSync(path.join(CWD, f)).size; } catch { /* missing */ }
308
- }
309
- result.dbSizeKB = Math.round(bytes / 1024);
310
-
311
- const memDb = path.join(CWD, '.swarm', 'memory.db');
312
- if (fs.existsSync(memDb)) {
313
- const Q = String.fromCharCode(34);
314
- // Two INDEPENDENT statements -- do NOT combine into one. Coupling the
315
- // vector count with the vector_indexes row count in a single statement
316
- // meant that on a DB missing the vector_indexes table (older/agentdb-
317
- // written DBs), the whole statement failed at PREPARE time (SQLite
318
- // compiles the full SQL before running), so the valid memory_entries
319
- // count was discarded too and the statusline showed Vectors 0 despite
320
- // thousands of real vectors. Split so a missing table can only zero the
321
- // HNSW flag, never the count. The init self-heal provisions the table so
322
- // the flag recovers on the next ruflo init / MCP start.
323
- const countSql = Q + 'SELECT COUNT(*) FROM memory_entries WHERE embedding IS NOT NULL;' + Q;
324
- const vc = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + countSql, 1500);
325
- if (vc) result.vectorCount = parseInt(vc, 10) || 0;
326
- // HNSW flag: separate statement. If vector_indexes is absent, sqlite3
327
- // exits non-zero and safeExec returns empty -- hasHnsw stays false (exact
328
- // original semantics: at least one index-config row present).
329
- const hnswSql = Q + 'SELECT COUNT(*) FROM vector_indexes;' + Q;
330
- const hn = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + hnswSql, 1500);
331
- if (hn) result.hasHnsw = (parseInt(hn, 10) || 0) > 0;
332
- }
333
- } catch { /* ignore */ }
334
- return result;
335
- }
336
-
337
- // Count test files via a bounded directory walk (no file reads).
338
- function getLocalTests() {
339
- let testFiles = 0;
340
- function countTests(dir, depth) {
341
- if ((depth || 0) > 4) return;
342
- try {
343
- if (!fs.existsSync(dir)) return;
344
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
345
- if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
346
- countTests(path.join(dir, e.name), (depth || 0) + 1);
347
- } else if (e.isFile() && (e.name.includes('.test.') || e.name.includes('.spec.') || e.name.startsWith('test_') || e.name.startsWith('spec_'))) {
348
- testFiles++;
349
- }
350
- }
351
- } catch { /* ignore */ }
352
- }
353
- for (const d of ['tests', 'test', '__tests__', 'src', 'v3']) countTests(path.join(CWD, d));
354
- return { testFiles, testCases: testFiles * 4 };
355
- }
356
-
357
- // Count configured hooks from project .claude/settings.json. Claude Code hooks
358
- // have no enabled/disabled flag, so every configured hook counts as enabled.
359
- function getLocalHooks() {
360
- const result = { enabled: 0, total: 0 };
361
- try {
362
- const settings = readJSON(path.join(CWD, '.claude', 'settings.json'));
363
- const hooks = settings && settings.hooks;
364
- if (hooks && typeof hooks === 'object') {
365
- let n = 0;
366
- for (const ev of Object.keys(hooks)) {
367
- const groups = hooks[ev];
368
- if (Array.isArray(groups)) {
369
- for (const g of groups) {
370
- if (g && Array.isArray(g.hooks)) n += g.hooks.length;
371
- }
372
- }
373
- }
374
- result.total = n;
375
- result.enabled = n;
376
- }
377
- } catch { /* ignore */ }
378
- return result;
379
- }
380
-
381
- // Best-effort integration block: DB presence + locally-configured stdio MCP
382
- // servers (project .mcp.json + global ~/.claude.json). Remote connectors are
383
- // account-managed and not present in local config, so they are not counted.
384
- function getLocalIntegration() {
385
- const integration = { mcpServers: { enabled: 0, total: 0 }, hasDatabase: false };
386
- try {
387
- for (const f of ['.swarm/memory.db', 'ruvector.db']) {
388
- if (fs.existsSync(path.join(CWD, f))) { integration.hasDatabase = true; break; }
389
- }
390
- const names = new Set();
391
- const projMcp = readJSON(path.join(CWD, '.mcp.json'));
392
- if (projMcp && projMcp.mcpServers) for (const k of Object.keys(projMcp.mcpServers)) names.add(k);
393
- const claudeJson = readJSON(path.join(os.homedir(), '.claude.json'));
394
- if (claudeJson) {
395
- if (claudeJson.mcpServers) for (const k of Object.keys(claudeJson.mcpServers)) names.add(k);
396
- const proj = claudeJson.projects && claudeJson.projects[CWD];
397
- if (proj && proj.mcpServers && !Array.isArray(proj.mcpServers)) {
398
- for (const k of Object.keys(proj.mcpServers)) names.add(k);
399
- }
400
- }
401
- integration.mcpServers.total = names.size;
402
- integration.mcpServers.enabled = names.size;
403
- } catch { /* ignore */ }
404
- return integration;
405
- }
406
-
407
- // ─── Security freshness overlay (ruvnet/ruflo#2776) ──────────────
408
- // The shipped CLI producer (dist/src/funnel/local-signals.js getSecurityStatus)
409
- // only ever emits PENDING / CLEAN / ISSUES — it captures `scannedAt` but never
410
- // inspects it, so a year-old scan renders 🛡 ✓ forever and the renderer's
411
- // STALE / IN_PROGRESS branches are unreachable. Worse, when CLI delegation
412
- // fails, the stale-while-revalidate cache (readCache() below) keeps serving
413
- // the pre-scan PENDING pill indefinitely, so a user who runs the advertised
414
- // `ruflo security scan` sees no change — the pill freezes at "scan pending".
415
- //
416
- // This overlay recomputes the security block from disk on EVERY render (same
417
- // pattern as adrs/agentdb/tests/hooks above), which:
418
- // 1) Makes STALE reachable — when the newest scan is older than
419
- // RUFLO_SCAN_STALE_HOURS (default 24h — matches the CVE feed refresh
420
- // cadence), report STALE regardless of what the cached CLI JSON says.
421
- // 2) Makes IN_PROGRESS reachable — when a `scan-in-progress` marker file
422
- // exists and is younger than SECURITY_IN_PROGRESS_MAX_MIN (guards against
423
- // a crashed scan leaving the marker behind).
424
- // 3) Caps the "scan pending" display window — if PENDING has been shown for
425
- // >RUFLO_SCAN_PENDING_CAP_MIN (default 30) without a completion write,
426
- // switch to STALE and stop rendering the yellow indicator. The tracker
427
- // lives in ~/.ruflo/statusline-scan-pending-since.json, keyed by CWD
428
- // hash so multiple project checkouts don't collide.
429
- // 4) Since this runs AFTER readCache() serves stale data, it bypasses the
430
- // "pill freezes at PENDING" freeze in defect 2 — the overlay reads
431
- // fresh disk state even when the CLI delegation is broken.
432
- const SECURITY_STALE_HOURS = Math.max(1, parseInt(process.env.RUFLO_SCAN_STALE_HOURS || '24', 10) || 24);
433
- const SECURITY_PENDING_CAP_MIN = Math.max(1, parseInt(process.env.RUFLO_SCAN_PENDING_CAP_MIN || '30', 10) || 30);
434
- const SECURITY_IN_PROGRESS_MAX_MIN = 30; // marker older than this = crashed scan; treat as absent
435
- const PENDING_TRACK_FILE = path.join(os.homedir(), '.ruflo', 'statusline-scan-pending-since.json');
436
- const CWD_KEY = require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 12);
437
-
438
- function readPendingSince() {
439
- try {
440
- if (!fs.existsSync(PENDING_TRACK_FILE)) return null;
441
- const raw = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8'));
442
- if (raw && typeof raw === 'object' && typeof raw[CWD_KEY] === 'number') return raw[CWD_KEY];
443
- } catch { /* ignore */ }
444
- return null;
445
- }
446
-
447
- function writePendingSince(ts) {
448
- try {
449
- let obj = {};
450
- if (fs.existsSync(PENDING_TRACK_FILE)) {
451
- try { obj = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8')) || {}; } catch { obj = {}; }
452
- }
453
- if (ts === null) { delete obj[CWD_KEY]; } else { obj[CWD_KEY] = ts; }
454
- fs.mkdirSync(path.dirname(PENDING_TRACK_FILE), { recursive: true, mode: 0o700 });
455
- fs.writeFileSync(PENDING_TRACK_FILE, JSON.stringify(obj), { encoding: 'utf-8', mode: 0o600 });
456
- } catch { /* ignore */ }
457
- }
458
-
459
- function getLocalSecurity(cliSecurity) {
460
- const base = (cliSecurity && typeof cliSecurity === 'object')
461
- ? Object.assign({}, cliSecurity)
462
- : { status: 'NONE', findings: 0, cvesFixed: 0, totalCves: 0 };
463
- base.findings = Math.max(0, base.findings || 0);
464
-
465
- const scanDir = path.join(CWD, '.claude', 'security-scans');
466
-
467
- // Detect a live in-progress marker (writer opts-in by writing this file).
468
- let inProgress = false;
469
- try {
470
- const marker = path.join(scanDir, 'scan-in-progress');
471
- if (fs.existsSync(marker)) {
472
- const ageMin = (Date.now() - fs.statSync(marker).mtimeMs) / 60000;
473
- if (ageMin < SECURITY_IN_PROGRESS_MAX_MIN) inProgress = true;
474
- }
475
- } catch { /* ignore */ }
476
-
477
- // Find newest scan-*.json by mtime and read its findings/timestamp.
478
- let newestPath = null;
479
- let newestMtime = 0;
480
- try {
481
- if (fs.existsSync(scanDir)) {
482
- for (const name of fs.readdirSync(scanDir)) {
483
- if (!name.startsWith('scan-') || !name.endsWith('.json')) continue;
484
- try {
485
- const st = fs.statSync(path.join(scanDir, name));
486
- if (st.mtimeMs > newestMtime) { newestMtime = st.mtimeMs; newestPath = path.join(scanDir, name); }
487
- } catch { /* ignore */ }
488
- }
489
- }
490
- } catch { /* ignore */ }
491
-
492
- if (newestPath) {
493
- // We have a scan on disk — the never-scanned pending tracker is no longer
494
- // relevant. Clear it so a re-created directory can start a fresh window.
495
- writePendingSince(null);
496
-
497
- let scannedAtMs = newestMtime;
498
- let findings = base.findings;
499
- try {
500
- const j = JSON.parse(fs.readFileSync(newestPath, 'utf-8'));
501
- // scannedAt (CLI producer's field name) OR timestamp (writer's field name).
502
- const isoStr = (j && (j.scannedAt || j.timestamp)) || null;
503
- if (isoStr) {
504
- const t = Date.parse(isoStr);
505
- if (!isNaN(t)) scannedAtMs = t;
506
- }
507
- // findings may be a number, an array, or nested in summary.total.
508
- if (j) {
509
- if (typeof j.findings === 'number') findings = j.findings;
510
- else if (Array.isArray(j.findings)) findings = j.findings.length;
511
- else if (j.summary && typeof j.summary.total === 'number') findings = j.summary.total;
512
- }
513
- } catch { /* ignore parse — fall back to mtime + cached findings */ }
514
-
515
- base.findings = Math.max(0, findings || 0);
516
- base.scannedAt = new Date(scannedAtMs).toISOString();
517
-
518
- const ageHours = (Date.now() - scannedAtMs) / 3600000;
519
- if (ageHours >= SECURITY_STALE_HOURS) {
520
- // Stale but findings still render red (a year-old ISSUES scan is still bad).
521
- base.status = 'STALE';
522
- } else if (inProgress) {
523
- base.status = 'IN_PROGRESS';
524
- } else if (base.findings > 0) {
525
- base.status = 'ISSUES';
526
- } else {
527
- base.status = 'CLEAN';
528
- }
529
- return base;
530
- }
531
-
532
- // No scan file. If a live marker exists, we're mid-scan.
533
- if (inProgress) {
534
- base.status = 'IN_PROGRESS';
535
- // Reset the pending tracker so, if the scan crashes mid-flight, the next
536
- // render starts a fresh N-minute pending window instead of an already-expired one.
537
- writePendingSince(null);
538
- return base;
539
- }
540
-
541
- // Truly never-scanned: track how long we've shown PENDING. After the cap,
542
- // escalate to STALE with the dim/gray glyph so the pill visibly stops
543
- // shouting for attention — the user has either ignored it for 30 min or
544
- // the scan is silently failing to write.
545
- let pendingSince = readPendingSince();
546
- if (pendingSince === null || typeof pendingSince !== 'number') {
547
- pendingSince = Date.now();
548
- writePendingSince(pendingSince);
549
- }
550
- const pendingAgeMin = (Date.now() - pendingSince) / 60000;
551
- base.status = (pendingAgeMin >= SECURITY_PENDING_CAP_MIN) ? 'STALE' : 'PENDING';
552
- return base;
553
- }
554
-
555
- // Cached pattern count. The store is routinely >10MB (14.5MB / 1277 patterns on
556
- // the machine this was diagnosed on, costing 53ms to read+parse), and the
557
- // statusline re-renders on every prompt, so the parse is keyed on the file's
558
- // mtime+size and reused until the store actually changes.
559
- const PATTERN_COUNT_CACHE = path.join(os.tmpdir(), 'ruflo-statusline-patterns-' + require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) + '.json');
560
-
561
- function countPatternsCached(file) {
562
- let stat;
563
- try { stat = fs.statSync(file); } catch { return 0; }
564
- const key = stat.mtimeMs + ':' + stat.size;
565
-
566
- try {
567
- const memo = JSON.parse(fs.readFileSync(PATTERN_COUNT_CACHE, 'utf-8'));
568
- if (memo && memo[file] && memo[file].key === key) return memo[file].count;
569
- } catch { /* no memo yet, or unreadable — fall through and recount */ }
570
-
571
- let count = 0;
572
- try {
573
- const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
574
- if (Array.isArray(raw)) count = raw.length;
575
- else if (raw && Array.isArray(raw.patterns)) count = raw.patterns.length;
576
- else if (raw && typeof raw === 'object') count = Object.keys(raw).length;
577
- } catch { return 0; }
578
-
579
- try {
580
- let memo = {};
581
- try { memo = JSON.parse(fs.readFileSync(PATTERN_COUNT_CACHE, 'utf-8')) || {}; } catch { /* start fresh */ }
582
- memo[file] = { key, count };
583
- fs.writeFileSync(PATTERN_COUNT_CACHE, JSON.stringify(memo));
584
- } catch { /* cache is an optimisation, never a requirement */ }
585
-
586
- return count;
587
- }
588
-
589
- // `system` is nominally CLI-populated, but the pattern store is on disk, so a
590
- // dead CLI must not render as "no learning". Without this, every candidate in
591
- // resolveCliBinCandidates() failing (a source-only marketplace checkout, or an
592
- // npx fetch that dies in a workspace root) silently degraded the brain to a
593
- // hardcoded 0% while the overlaid segments beside it kept showing live data —
594
- // indistinguishable from a genuine zero. Same reasoning as getLocalAgentDB(),
595
- // which had to start reading locally after the statusline reported "Vectors 0"
596
- // on a database holding thousands.
597
- function getLocalIntelligence(current) {
598
- const base = (current && typeof current === 'object') ? current : {};
599
- if (typeof base.intelligencePct === 'number' && base.intelligencePct > 0) return base;
600
-
601
- for (const file of [
602
- path.join(CWD, '.claude-flow', 'neural', 'patterns.json'),
603
- path.join(os.homedir(), '.claude-flow', 'neural', 'patterns.json'),
604
- ]) {
605
- const n = countPatternsCached(file);
606
- if (n > 0) {
607
- base.intelligencePct = Math.min(100, Math.floor(n / 10));
608
- return base;
609
- }
610
- }
611
-
612
- // No CLI value and no local store: unknown, which is not the same as zero.
613
- base.intelligencePct = null;
614
- return base;
615
- }
616
-
617
- // Overlay every locally-derived block onto the CLI data (mutates in place).
618
- function applyLocalOverlays(data) {
619
- data.adrs = getLocalADRCount();
620
- data.agentdb = getLocalAgentDB();
621
- data.tests = getLocalTests();
622
- data.hooks = getLocalHooks();
623
- data.integration = getLocalIntegration();
624
- // Security overlay: recompute freshness from disk on every render so cached
625
- // CLI JSON can never freeze the pill at PENDING. See getLocalSecurity() above.
626
- data.security = getLocalSecurity(data.security);
627
- data.system = getLocalIntelligence(data.system);
628
- return data;
629
- }
630
-
631
- // Minimal local fallback when the CLI is not installed or times out.
632
- // Returns a structure that matches the CLI JSON schema so the renderer works.
633
- function buildLocalFallback() {
634
- const memMB = Math.floor(process.memoryUsage().heapUsed / 1024 / 1024);
635
-
636
- return applyLocalOverlays({
637
- user: { name: 'user', gitBranch: '', modelName: 'Claude Code' },
638
- v3Progress: { domainsCompleted: 0, totalDomains: 5, dddProgress: 0, patternsLearned: 0, sessionsCompleted: 0 },
639
- security: { status: 'NONE', findings: 0, cvesFixed: 0, totalCves: 0 },
640
- swarm: { activeAgents: 0, maxAgents: CONFIG.maxAgents, coordinationActive: false },
641
- system: { memoryMB: memMB, contextPct: 0, intelligencePct: null, subAgents: 0 },
642
- lastUpdated: new Date().toISOString(),
643
- });
644
- }
645
-
646
- // ANSI colors
647
- const c = {
648
- reset: '\x1b[0m',
649
- bold: '\x1b[1m',
650
- dim: '\x1b[2m',
651
- red: '\x1b[0;31m',
652
- green: '\x1b[0;32m',
653
- yellow: '\x1b[0;33m',
654
- blue: '\x1b[0;34m',
655
- purple: '\x1b[0;35m',
656
- cyan: '\x1b[0;36m',
657
- brightRed: '\x1b[1;31m',
658
- brightGreen: '\x1b[1;32m',
659
- brightYellow: '\x1b[1;33m',
660
- brightBlue: '\x1b[1;34m',
661
- brightPurple: '\x1b[1;35m',
662
- brightCyan: '\x1b[1;36m',
663
- brightWhite: '\x1b[1;37m',
664
- };
665
-
666
- // Safe execSync with strict timeout (returns empty string on failure)
667
- function safeExec(cmd, timeoutMs) {
668
- try {
669
- return execSync(cmd, {
670
- encoding: 'utf-8',
671
- timeout: timeoutMs || 2000,
672
- stdio: ['pipe', 'pipe', 'pipe'],
673
- // Windows: without this, every execSync spawns cmd.exe /d /s /c which
674
- // flashes a visible console window every render (~1/min via the 60s
675
- // cache TTL). windowsHide runs the child in a hidden window instead.
676
- // No-op on POSIX. Fix for #2XXX (user report: "cmd prompt keeps opening").
677
- windowsHide: true,
678
- }).trim();
679
- } catch {
680
- return '';
681
- }
682
- }
683
-
684
- // Safe JSON file reader (returns null on failure)
685
- function readJSON(filePath) {
686
- try {
687
- if (fs.existsSync(filePath)) {
688
- return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
689
- }
690
- } catch { /* ignore */ }
691
- return null;
692
- }
693
-
694
- // ─── Git info (pure-Node / single exec — needed for branch display) ──────────
695
-
696
- // #3045 — getGitInfo() shells out to a 5-command chain per render. Uncached,
697
- // it queues subprocesses faster than they finish on large repos with multiple
698
- // concurrent Claude Code sessions, producing the pileup reported in that
699
- // issue (hundreds of orphan git children, load-avg in the hundreds). Cache
700
- // the parsed result in a per-cwd tmp file with a short TTL — dirty/branch
701
- // status doesn't need per-render freshness, and 5s is short enough to feel
702
- // live.
703
- const GIT_INFO_CACHE_FILE = path.join(
704
- os.tmpdir(),
705
- 'ruflo-statusline-gitinfo-' +
706
- require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) +
707
- '.json'
708
- );
709
- const GIT_INFO_TTL_MS = 5000;
710
-
711
- function readGitInfoCache() {
712
- try {
713
- if (fs.existsSync(GIT_INFO_CACHE_FILE)) {
714
- const raw = JSON.parse(fs.readFileSync(GIT_INFO_CACHE_FILE, 'utf-8'));
715
- if (raw && typeof raw._ts === 'number' && Date.now() - raw._ts < GIT_INFO_TTL_MS && raw.data) {
716
- return raw.data;
717
- }
718
- }
719
- } catch { /* ignore */ }
720
- return null;
721
- }
722
-
723
- function writeGitInfoCache(data) {
724
- try {
725
- fs.writeFileSync(
726
- GIT_INFO_CACHE_FILE,
727
- JSON.stringify({ _ts: Date.now(), data }),
728
- 'utf-8'
729
- );
730
- } catch { /* ignore */ }
731
- }
732
-
733
- function getGitInfo() {
734
- const cached = readGitInfoCache();
735
- if (cached) return cached;
736
-
737
- const result = {
738
- name: path.basename(CWD) || 'project', gitBranch: '', modified: 0, untracked: 0,
739
- staged: 0, ahead: 0, behind: 0,
740
- };
741
-
742
- const script = [
743
- 'git rev-parse --show-toplevel 2>/dev/null || pwd',
744
- 'echo "---SEP---"',
745
- 'git config user.name 2>/dev/null || echo user',
746
- 'echo "---SEP---"',
747
- 'git branch --show-current 2>/dev/null',
748
- 'echo "---SEP---"',
749
- 'git status --porcelain 2>/dev/null',
750
- 'echo "---SEP---"',
751
- 'git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"',
752
- ].join('; ');
753
-
754
- const raw = safeExec("sh -c '" + script + "'", 3000);
755
- if (!raw) {
756
- // Cache even the empty result — if git is slow/unavailable, we don't want
757
- // to re-attempt on every render either.
758
- writeGitInfoCache(result);
759
- return result;
760
- }
761
-
762
- const parts = raw.split('---SEP---').map(function(s) { return s.trim(); });
763
- if (parts.length >= 5) {
764
- const projectName = path.basename(parts[0] || CWD) || path.basename(CWD) || 'project';
765
- const authorName = parts[1] || 'user';
766
- result.name = CONFIG.identityMode === 'author' ? authorName : projectName;
767
- result.gitBranch = parts[2] || '';
768
-
769
- if (parts[3]) {
770
- for (const line of parts[3].split('\n')) {
771
- if (!line || line.length < 2) continue;
772
- const x = line[0], y = line[1];
773
- if (x === '?' && y === '?') { result.untracked++; continue; }
774
- if (x !== ' ' && x !== '?') result.staged++;
775
- if (y !== ' ' && y !== '?') result.modified++;
776
- }
777
- }
778
-
779
- const ab = (parts[4] || '0 0').split(/\s+/);
780
- result.ahead = parseInt(ab[0]) || 0;
781
- result.behind = parseInt(ab[1]) || 0;
782
- }
783
-
784
- writeGitInfoCache(result);
785
- return result;
786
- }
787
-
788
- // Detect model name from Claude config (pure file reads, no exec)
789
- function getModelName() {
790
- try {
791
- const claudeConfig = readJSON(path.join(os.homedir(), '.claude.json'));
792
- if (claudeConfig && claudeConfig.projects) {
793
- for (const [projectPath, projectConfig] of Object.entries(claudeConfig.projects)) {
794
- if (CWD === projectPath || CWD.startsWith(projectPath + '/')) {
795
- const usage = projectConfig.lastModelUsage;
796
- if (usage) {
797
- const ids = Object.keys(usage);
798
- if (ids.length > 0) {
799
- let modelId = ids[ids.length - 1];
800
- let latest = 0;
801
- for (const id of ids) {
802
- const ts = usage[id] && usage[id].lastUsedAt ? new Date(usage[id].lastUsedAt).getTime() : 0;
803
- if (ts > latest) { latest = ts; modelId = id; }
804
- }
805
- if (modelId.includes('opus')) return 'Opus 4.8';
806
- if (modelId.includes('sonnet')) return 'Sonnet 4.6';
807
- if (modelId.includes('haiku')) return 'Haiku 4.5';
808
- return modelId.split('-').slice(1, 3).join(' ');
809
- }
810
- }
811
- break;
812
- }
813
- }
814
- }
815
- } catch { /* ignore */ }
816
-
817
- // Fallback: settings.json model field
818
- const settings = getSettings();
819
- if (settings && settings.model) {
820
- const m = settings.model;
821
- if (m.includes('opus')) return 'Opus 4.8';
822
- if (m.includes('sonnet')) return 'Sonnet 4.6';
823
- if (m.includes('haiku')) return 'Haiku 4.5';
824
- }
825
- return 'Claude Code';
826
- }
827
-
828
- // ─── Stdin reader (Claude Code pipes session JSON) ──────────────
829
- // Claude Code sends session JSON via stdin. Read synchronously so the
830
- // script works both when invoked by Claude Code (stdin has JSON) and
831
- // when run manually from terminal (stdin is empty/tty).
832
- let _stdinData = null;
833
- function getStdinData() {
834
- if (_stdinData !== undefined && _stdinData !== null) return _stdinData;
835
- try {
836
- if (process.stdin.isTTY) { _stdinData = null; return null; }
837
- const chunks = [];
838
- const buf = Buffer.alloc(4096);
839
- let bytesRead;
840
- try {
841
- while ((bytesRead = fs.readSync(0, buf, 0, buf.length, null)) > 0) {
842
- chunks.push(buf.slice(0, bytesRead));
843
- }
844
- } catch { /* EOF or read error */ }
845
- const raw = Buffer.concat(chunks).toString('utf-8').trim();
846
- _stdinData = (raw && raw.startsWith('{')) ? JSON.parse(raw) : null;
847
- } catch {
848
- _stdinData = null;
849
- }
850
- return _stdinData;
851
- }
852
-
853
- function getModelFromStdin() {
854
- const data = getStdinData();
855
- return (data && data.model && data.model.display_name) ? data.model.display_name : null;
856
- }
857
-
858
- function getContextFromStdin() {
859
- const data = getStdinData();
860
- if (data && data.context_window) {
861
- return { usedPct: Math.floor(data.context_window.used_percentage || 0) };
862
- }
863
- return null;
864
- }
865
-
866
- function getCostFromStdin() {
867
- const data = getStdinData();
868
- if (data && data.cost) {
869
- const durationMs = data.cost.total_duration_ms || 0;
870
- const mins = Math.floor(durationMs / 60000);
871
- const secs = Math.floor((durationMs % 60000) / 1000);
872
- return {
873
- costUsd: data.cost.total_cost_usd || 0,
874
- duration: mins > 0 ? mins + 'm' + secs + 's' : secs + 's',
875
- };
876
- }
877
- return null;
878
- }
879
-
880
- // Compares dotted-numeric version strings (e.g. "3.27.1" vs "3.27.10").
881
- // Returns >0 if a>b, <0 if a<b, 0 if equal-as-far-as-parseable. Deliberately
882
- // simple (no prerelease/build-metadata handling) — this only orders local
883
- // package.json versions against each other, never anything untrusted from
884
- // a payload, so a full semver implementation would be dead weight here.
885
- function compareVersions(a, b) {
886
- const pa = String(a).split('.').map((n) => parseInt(n, 10));
887
- const pb = String(b).split('.').map((n) => parseInt(n, 10));
888
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
889
- const na = Number.isFinite(pa[i]) ? pa[i] : 0;
890
- const nb = Number.isFinite(pb[i]) ? pb[i] : 0;
891
- if (na !== nb) return na - nb;
892
- }
893
- return 0;
894
- }
895
-
896
- // #2742: when CWD is a linked git worktree, it has no node_modules of its
897
- // own (worktrees don't get their own `npm install`), so every CWD-relative
898
- // probe in getPkgVersion() misses and the version silently falls back to
899
- // the baked-in default — even though the main repo's install a few
900
- // directories away is perfectly resolvable. A linked worktree's `.git` is
901
- // a plain FILE (not a directory) containing `gitdir: <main>/.git/worktrees/
902
- // <name>`; walk up from CWD to find it, parse the pointer, and strip the
903
- // trailing `.git/worktrees/<name>` segment to recover the main repo root.
904
- // Pure fs — no `git rev-parse` spawn (statusline renders are latency-
905
- // sensitive; this doc comment's neighbors are explicit about avoiding
906
- // spawns in the render path).
907
- function resolveWorktreeMainRoot() {
908
- try {
909
- let dir = CWD;
910
- for (;;) {
911
- const dotGit = path.join(dir, '.git');
912
- if (fs.existsSync(dotGit)) {
913
- if (fs.statSync(dotGit).isFile()) {
914
- const contents = fs.readFileSync(dotGit, 'utf-8');
915
- const m = contents.match(/^gitdir:\s*(.+)$/m);
916
- const wtGitDir = m && m[1].trim();
917
- if (wtGitDir) {
918
- // Git writes this pointer with forward slashes even on Windows
919
- // (a git-for-windows convention for its own internal files) —
920
- // path.sep (backslash on win32) never matches, so normalize
921
- // before searching rather than building an OS-specific marker.
922
- const normalized = wtGitDir.replace(/\\/g, '/');
923
- const marker = '/.git/worktrees/';
924
- const idx = normalized.lastIndexOf(marker);
925
- if (idx > 0) return normalized.slice(0, idx);
926
- }
927
- }
928
- return null; // a real (non-worktree) .git dir — nothing to resolve
929
- }
930
- const parent = path.dirname(dir);
931
- if (parent === dir) return null; // reached filesystem root
932
- dir = parent;
933
- }
934
- } catch {
935
- return null;
936
- }
937
- }
938
-
939
- function getPkgVersion() {
940
- // Baked in at generation time from the real running CLI's own resolved
941
- // version (see generateStatuslineScript()'s doc comment) — correct even
942
- // when this renders via a pure npx invocation with no local install for
943
- // the candidate scan below to find.
944
- let ver = "3.32.8";
945
- try {
946
- const home = os.homedir();
947
- const pkgPaths = [
948
- path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'package.json'),
949
- path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'package.json'),
950
- path.join(CWD, 'node_modules', 'ruflo', 'package.json'),
951
- path.join(CWD, 'v3', '@claude-flow', 'cli', 'package.json'),
952
- ];
953
- // #2742: CWD is a linked git worktree with no node_modules of its own —
954
- // probe the main repo's install too, so a worktree session shows the
955
- // same version a main-repo session would.
956
- const worktreeMainRoot = resolveWorktreeMainRoot();
957
- if (worktreeMainRoot) {
958
- pkgPaths.push(
959
- path.join(worktreeMainRoot, 'node_modules', '@claude-flow', 'cli', 'package.json'),
960
- path.join(worktreeMainRoot, 'node_modules', 'ruflo', 'package.json'),
961
- path.join(worktreeMainRoot, 'v3', '@claude-flow', 'cli', 'package.json'),
962
- );
963
- }
964
- // #2221: global installs (npm i -g ruflo) live outside CWD/node_modules, so the
965
- // probes above all miss and the version falls back to the hard-coded default.
966
- // Derive the global node_modules dir from the running node binary (no npm spawn —
967
- // statusline renders often). Covers nvm/mise (bin/../lib/node_modules) and Windows
968
- // (bin/node_modules) layouts.
969
- try {
970
- const binDir = path.dirname(process.execPath);
971
- const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
972
- // #2221 follow-up: a custom npm prefix (e.g. ~/.npm-global) is decoupled from
973
- // the node binary location, so the binDir-derived probes above all miss. Also
974
- // probe the npm prefix from the environment and the common ~/.npm-global default.
975
- for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
976
- if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
977
- }
978
- for (const gm of globalModuleDirs) {
979
- pkgPaths.push(
980
- path.join(gm, 'ruflo', 'package.json'),
981
- path.join(gm, '@claude-flow', 'cli', 'package.json'),
982
- );
983
- }
984
- } catch { /* ignore */ }
985
- // Pick the HIGHEST version among every candidate that exists, not the
986
- // first one found. The marketplace plugin path is probed first (list
987
- // order above), but Claude Code's own plugin marketplace mechanism
988
- // syncs on its own git-pull cadence, independent of npm publishes — a
989
- // freshly-published npm version can sit alongside a stale marketplace
990
- // checkout for a while (observed live: marketplace one release behind
991
- // right after a publish). Taking the first EXISTING candidate meant the
992
- // header could show a stale version even when a newer install (e.g.
993
- // node_modules/@claude-flow/cli from a plain npm install) was sitting right there.
994
- let found = false;
995
- for (const p of pkgPaths) {
996
- if (!fs.existsSync(p)) continue;
997
- try {
998
- const pkg = JSON.parse(fs.readFileSync(p, 'utf-8'));
999
- if (pkg && typeof pkg.version === 'string' && pkg.version.length > 0) {
1000
- if (!found || compareVersions(pkg.version, ver) > 0) ver = pkg.version;
1001
- found = true;
1002
- }
1003
- } catch { /* ignore */ }
1004
- }
1005
- } catch { /* ignore */ }
1006
- return ver;
1007
- }
1008
-
1009
- // ─── Rendering ──────────────────────────────────────────────────
1010
-
1011
- function progressBar(current, total) {
1012
- const width = 5;
1013
- const filled = Math.round((current / total) * width);
1014
- return '[' + '●'.repeat(filled) + '○'.repeat(width - filled) + ']';
1015
- }
1016
-
1017
- function generateStatusline() {
1018
- const d = getStatuslineData();
1019
- const git = getGitInfo();
1020
- const modelName = getModelFromStdin() || (d.user && d.user.modelName) || 'Claude Code';
1021
- const ctxInfo = getContextFromStdin();
1022
- const costInfo = getCostFromStdin();
1023
- // Named RUFLO_VERSION (not pkgVersion) so the #1951 regression guard
1024
- // (scripts/audit-fix-invariants.mjs) can pin its presence in the emitted
1025
- // .cjs artifact — without it the header silently reverts to a hard-coded
1026
- // "RuFlo V3.5" for anyone whose install doesn't match the first probe path.
1027
- const RUFLO_VERSION = getPkgVersion();
1028
-
1029
- const progress = d.v3Progress || {};
1030
- const security = d.security || {};
1031
- const swarm = d.swarm || {};
1032
- const system = d.system || {};
1033
- const adrs = d.adrs || {};
1034
- const hooks = d.hooks || {};
1035
- const agentdb = d.agentdb || {};
1036
- const tests = d.tests || {};
1037
-
1038
- const domainsCompleted = progress.domainsCompleted || 0;
1039
- const totalDomains = progress.totalDomains || 5;
1040
- const dddProgress = progress.dddProgress || 0;
1041
- const patternsLearned = progress.patternsLearned || 0;
1042
- const activeAgents = swarm.activeAgents || 0;
1043
- const maxAgents = swarm.maxAgents || CONFIG.maxAgents;
1044
- const coordinationActive = swarm.coordinationActive || false;
1045
- // null/undefined means "no source answered", which must not collapse to 0 --
1046
- // that is exactly the ambiguity this segment used to present.
1047
- const intelligencePct = (typeof system.intelligencePct === 'number')
1048
- ? system.intelligencePct
1049
- : null;
1050
- const memoryMB = system.memoryMB || 0;
1051
- const subAgents = system.subAgents || 0;
1052
- const findings = Math.max(0, security.findings || 0);
1053
- const secStatus = security.status || 'NONE';
1054
- const adrCount = adrs.count || 0;
1055
- const adrImpl = adrs.implemented || 0;
1056
- const hooksEnabled = hooks.enabled || 0;
1057
- const hooksTotal = hooks.total || 0;
1058
- const vectorCount = agentdb.vectorCount || 0;
1059
- const hasHnsw = agentdb.hasHnsw || false;
1060
- const dbSizeKB = agentdb.dbSizeKB || 0;
1061
- const testFiles = tests.testFiles || 0;
1062
- const testCases = tests.testCases || testFiles * 4;
1063
-
1064
- const lines = [];
1065
-
1066
- // 3-line design (fits Claude Code's visible statusline area — line 4+ gets
1067
- // replaced by the system guidance / input prompt line):
1068
- // Line 1 — Header (RuFlo version · git · model · timing · context · cost)
1069
- // Line 2 — Compressed ops (Swarm · Hooks · 🧠 · 💾 · Health)
1070
- // Line 3 — Promo / disclosure row (funnel surface, ADR-301)
1071
-
1072
- // ─── Line 1: header ────────────────────────────────────────────
1073
- let header = c.bold + c.brightPurple + '▊ RuFlo V' + RUFLO_VERSION + ' ' + c.reset;
1074
- header += (coordinationActive ? c.brightCyan : c.dim) + '● ' + c.brightCyan + git.name + c.reset;
1075
- if (git.gitBranch) {
1076
- header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightBlue + '⏇ ' + git.gitBranch + c.reset;
1077
- const changes = git.modified + git.staged + git.untracked;
1078
- if (changes > 0) {
1079
- let ind = '';
1080
- if (git.staged > 0) ind += c.brightGreen + '+' + git.staged + c.reset;
1081
- if (git.modified > 0) ind += c.brightYellow + '~' + git.modified + c.reset;
1082
- if (git.untracked > 0) ind += c.dim + '?' + git.untracked + c.reset;
1083
- header += ' ' + ind;
1084
- }
1085
- if (git.ahead > 0) header += ' ' + c.brightGreen + '↑' + git.ahead + c.reset;
1086
- if (git.behind > 0) header += ' ' + c.brightRed + '↓' + git.behind + c.reset;
1087
- }
1088
- header += ' ' + c.dim + '│' + c.reset + ' ' + c.purple + modelName + c.reset;
1089
- const duration = costInfo ? costInfo.duration : '';
1090
- if (duration) header += ' ' + c.dim + '│' + c.reset + ' ' + c.cyan + '⏱ ' + duration + c.reset;
1091
- if (ctxInfo && ctxInfo.usedPct > 0) {
1092
- const ctxColor = ctxInfo.usedPct >= 90 ? c.brightRed : ctxInfo.usedPct >= 70 ? c.brightYellow : c.brightGreen;
1093
- header += ' ' + c.dim + '│' + c.reset + ' ' + ctxColor + '● ' + ctxInfo.usedPct + '% ctx' + c.reset;
1094
- }
1095
- if (!CONFIG.hideCost && costInfo && costInfo.costUsd > 0) {
1096
- header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightYellow + CONFIG.costSymbol + costInfo.costUsd.toFixed(2) + c.reset;
1097
- }
1098
- lines.push(header);
1099
-
1100
- // ─── Line 2: compressed ops ────────────────────────────────────
1101
- // Everything actionable in one dense row. Show only what changes what you
1102
- // do next; diagnostic detail moves to `ruflo status --verbose`.
1103
- const agentsColor = activeAgents > 0 ? c.brightGreen : c.dim;
1104
- const hooksColor = hooksEnabled > 0 ? c.brightGreen : c.dim;
1105
- const intellColor = intelligencePct === null ? c.dim : intelligencePct >= 80 ? c.brightGreen : intelligencePct >= 40 ? c.brightYellow : c.dim;
1106
- const swarmInd = coordinationActive ? c.brightGreen + '◉' + c.reset + ' ' : c.dim + '○' + c.reset + ' ';
1107
- const healthAllGreen = (secStatus === 'CLEAN' || secStatus === 'NONE') && findings === 0;
1108
- const opsParts = [];
1109
- opsParts.push(c.cyan + 'Swarm ' + swarmInd + agentsColor + activeAgents + c.reset + '/' + c.brightWhite + maxAgents + c.reset);
1110
- if (subAgents > 0) opsParts.push(c.brightPurple + '👥 ' + subAgents + c.reset);
1111
- opsParts.push(c.cyan + 'Hooks ' + hooksColor + hooksEnabled + c.reset + '/' + c.brightWhite + hooksTotal + c.reset);
1112
- opsParts.push(intellColor + '🧠 ' + (intelligencePct === null ? '—' : intelligencePct + '%') + c.reset);
1113
- opsParts.push(c.brightCyan + '💾 ' + memoryMB + 'MB' + c.reset);
1114
- // Health: one glyph when green, terse copy when there's something to act on.
1115
- if (healthAllGreen) {
1116
- opsParts.push(c.brightGreen + '🛡 ✓' + c.reset);
1117
- } else {
1118
- // #2776: STALE gets dim/gray (distinct from the actionable yellow of
1119
- // PENDING/IN_PROGRESS) so a stale pill visibly stops shouting for
1120
- // attention — the user can act on the "run ruflo security scan" prompt or
1121
- // ignore it without a permanently-yellow indicator.
1122
- if (secStatus === 'PENDING') opsParts.push(c.brightYellow + '🛡 scan pending' + c.reset);
1123
- else if (secStatus === 'IN_PROGRESS') opsParts.push(c.brightYellow + '🛡 scanning…' + c.reset);
1124
- else if (secStatus === 'ISSUES') opsParts.push(c.brightRed + '🛡 findings' + c.reset);
1125
- else if (secStatus === 'STALE') opsParts.push(c.dim + '🛡 scan stale' + c.reset);
1126
- else if (secStatus !== 'NONE' && secStatus !== 'CLEAN') opsParts.push(c.brightRed + '🛡 ' + secStatus.toLowerCase() + c.reset);
1127
- if (findings > 0) {
1128
- opsParts.push(c.brightRed + '⚠ ' + findings + ' finding' + (findings === 1 ? '' : 's') + c.reset);
1129
- }
1130
- }
1131
- lines.push(opsParts.join(' ' + c.dim + '·' + c.reset + ' '));
1132
-
1133
- // ─── Line 3: promo / disclosure / insight ───────────────────────
1134
- // Colored by content kind so it reads as *what it is*, not as noise:
1135
- // disclosure → brightCyan (announcement / capability link)
1136
- // promotional → brightPurple (Cognitum sponsor spot)
1137
- // educational → yellow (a tip)
1138
- // insight → brightRed (environment/task-aware, local, actionable —
1139
- // distinct from remote content on purpose)
1140
- const promoRow = getPromoRow(d);
1141
- if (promoRow) {
1142
- const kind = (d && d.promo && d.promo.kind) || 'disclosure';
1143
- const promoColor = kind === 'promotional' ? c.brightPurple
1144
- : kind === 'educational' ? c.yellow
1145
- : kind === 'insight' ? c.brightRed
1146
- : c.brightCyan;
1147
- lines.push(promoColor + promoRow + c.reset);
1148
- }
1149
-
1150
- // Trailing blank line so Claude Code's input prompt gets breathing room
1151
- // instead of butting directly against the last statusline row.
1152
- return lines.join('\n') + '\n';
1153
- }
1154
-
1155
- // ─── Funnel promo row (ADR-301) ─────────────────────────────────
1156
- // Allowlist for OSC 8 hyperlink targets. Ships in code (not in payload) so
1157
- // no message can smuggle a link to an unapproved host.
1158
- //
1159
- // The final destination hosts (cognitum.one / agentics.org) AND the
1160
- // click-redirect host are both allowlisted here: promo.ts routes every
1161
- // clickable message through the server-side click-redirect (ADR-311 §7)
1162
- // so promo_open + geo are captured before the 302 to the real target —
1163
- // so the OSC 8 link the renderer emits points at the redirect host, not
1164
- // the final destination directly.
1165
- const PROMO_LINK_HOSTS = new Set([
1166
- 'cognitum.one', 'www.cognitum.one', 'docs.cognitum.one',
1167
- // agentics.org — OSS foundation, distinct sponsor domain. Kept in sync
1168
- // with messages.ts ALLOWED_URL_HOSTS.
1169
- 'agentics.org', 'www.agentics.org',
1170
- // Click-redirect host (funnel.ruv.io once its TLS cert is live; the raw
1171
- // Cloud Run hostname is allowlisted too since event-transport.ts /
1172
- // message-transport.ts / attribution.ts currently point at it as a TEMP
1173
- // fallback while the domain mapping's cert provisions).
1174
- 'funnel.ruv.io',
1175
- 'cognitum-analytics-63rzcdswba-uc.a.run.app',
1176
- ]);
1177
-
1178
- // Emit OSC 8 hyperlinks unless the environment is known-broken. tmux mangles
1179
- // raw OSC 8 (see anthropics/claude-code#27047) — opt in via env if wrapped.
1180
- function terminalSupportsHyperlinks() {
1181
- if (process.env.CI || process.env.GITHUB_ACTIONS) return false;
1182
- if (process.env.TERM === 'dumb') return false;
1183
- if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_STATUSLINE_HYPERLINKS || ''))) return false;
1184
- if (process.env.TMUX && !process.env.RUFLO_STATUSLINE_HYPERLINKS_TMUX) return false;
1185
- return true;
1186
- }
1187
-
1188
- // Wrap a label in an OSC 8 hyperlink escape sequence. Falls back to the raw
1189
- // label whenever the URL is not an allowlisted https target, when the terminal
1190
- // can't render hyperlinks, or when parsing fails — a broken link must never
1191
- // leave a raw URL or stray escape in the statusline output.
1192
- function safeTerminalLink(label, url) {
1193
- if (!terminalSupportsHyperlinks()) return label;
1194
- if (typeof url !== 'string' || url.length === 0) return label;
1195
- let parsed;
1196
- try { parsed = new URL(url); } catch { return label; }
1197
- if (parsed.protocol !== 'https:') return label;
1198
- if (!PROMO_LINK_HOSTS.has(parsed.hostname)) return label;
1199
- const cleanLabel = String(label).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '');
1200
- if (cleanLabel.length === 0) return label;
1201
- const ESC = '\u001b';
1202
- return ESC + ']8;;' + parsed.href + ESC + '\\' + cleanLabel + ESC + ']8;;' + ESC + '\\';
1203
- }
1204
-
1205
- function getPromoRow(d) {
1206
- try {
1207
- if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
1208
- if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_FUNNEL || ''))) return null;
1209
- const promo = d && d.promo;
1210
- if (!promo || typeof promo.text !== 'string') return null;
1211
- // Strip control chars / ANSI / bidi overrides — promo copy is data and
1212
- // must never emit its own terminal sequences. Hard-cap length AFTER the
1213
- // strip; append an ellipsis when the cap fires so the row visibly reads
1214
- // as truncated instead of chopping a word mid-character (was: silent
1215
- // slice(0,100) that could produce output that looked like corrupt data).
1216
- const MAX_LEN = 100;
1217
- const sanitized = promo.text
1218
- .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
1219
- ;
1220
- const text = (sanitized.length > MAX_LEN ? sanitized.slice(0, MAX_LEN - 1).trimEnd() + '…' : sanitized).trim();
1221
- if (text.length === 0) return null;
1222
- // Split the label from the trailing "· manage: ruflo settings" instruction
1223
- // so each part gets styling that matches what it actually IS:
1224
- // 1. label — OSC 8 hyperlink + underline. A real clickable link.
1225
- // 2. "manage:" — dim. Just a connector word, no action implied.
1226
- // 3. "ruflo settings" — bold/bright, NOT underlined. This is a shell
1227
- // command the user TYPES, not a link they CLICK — a terminal can
1228
- // never safely execute a command from a click (that would let any
1229
- // server-served message run arbitrary commands), so we deliberately
1230
- // avoid the underline/OSC8 cues that imply "clickable". Bold+bright
1231
- // instead signals "this is the important bit — copy/type it".
1232
- // Educational tips have no manage tail and no URL — plain text through.
1233
- const manageIdx = text.indexOf(' · manage: ');
1234
- const label = manageIdx > 0 ? text.slice(0, manageIdx) : text;
1235
- const manageWord = manageIdx > 0 ? ' · manage: ' : '';
1236
- const command = manageIdx > 0 ? text.slice(manageIdx + manageWord.length) : '';
1237
- const UL_ON = '\u001b[4m';
1238
- const UL_OFF = '\u001b[24m';
1239
- const DIM_ON = '\u001b[2m';
1240
- const DIM_OFF = '\u001b[22m';
1241
- const BOLD_ON = '\u001b[1m';
1242
- const BOLD_OFF = '\u001b[22m';
1243
- const FG_BRIGHT_WHITE = '';
1244
- // Reset FG to default so the caller's row-color code resumes coloring the
1245
- // rest of the row after the command portion. Without this the row-color
1246
- // escape wouldn't visibly re-apply because we already emitted an explicit FG.
1247
- const FG_DEFAULT = '';
1248
- // Some hosts (Claude Code's Windows UI, cmd.exe, older mintty) don't
1249
- // render OSC 8 hyperlinks as clickable — the label just underlines and
1250
- // clicks do nothing. Append a "(domain)" suffix so the destination is
1251
- // visible/copyable everywhere. Wrap the suffix in OSC 8 too so terminals
1252
- // that DO support hyperlinks give users TWO click targets (label AND
1253
- // domain hint) instead of one — some Windows hosts render one but not
1254
- // the other depending on how the statusline row is parsed.
1255
- // Only for URLs (not educational tips), and only when the label doesn't
1256
- // already end in the domain to avoid duplication.
1257
- let visibleUrlHint = '';
1258
- if (promo.url) {
1259
- try {
1260
- const host = new URL(promo.url).hostname.replace(/^www\./, '');
1261
- // Strip the click-redirect wrapper so users see the FINAL destination,
1262
- // not funnel.ruv.io. If the URL is /v1/click/<id>?to=<encoded>, pull the target.
1263
- let displayHost = host;
1264
- try {
1265
- const to = new URL(promo.url).searchParams.get('to');
1266
- if (to) displayHost = new URL(to).hostname.replace(/^www\./, '');
1267
- } catch { /* not a click-redirect, keep the raw host */ }
1268
- if (displayHost && !label.toLowerCase().endsWith(displayHost.toLowerCase())) {
1269
- // safeTerminalLink returns the plain string if URL isn't allowlisted
1270
- // or the terminal can't do OSC 8 — either way the domain stays visible.
1271
- const clickableDomain = safeTerminalLink(displayHost, promo.url);
1272
- visibleUrlHint = DIM_ON + ' (' + clickableDomain + ')' + DIM_OFF;
1273
- }
1274
- } catch { /* malformed URL — omit hint, never break the row */ }
1275
- }
1276
- // "Entire row clickable" (user request) — wrap the whole assembled
1277
- // string in ONE OSC 8 hyperlink instead of just the label. The command
1278
- // portion keeps its bold + bright-white treatment (no underline) so it
1279
- // still VISUALLY reads as a shell command the user should type, not a
1280
- // link — but if the user clicks anywhere on the row (label, domain
1281
- // hint, connector, even the command text), the terminal opens the URL.
1282
- // Clicking DOES NOT execute the command; it just opens the target URL,
1283
- // which is safe. Terminals that ignore OSC 8 render the whole row as
1284
- // styled text and no click behavior — the previous fallback (visible
1285
- // domain suffix) still keeps the destination readable.
1286
- const wrapWholeRowInHyperlink = (assembled) => {
1287
- if (!promo.url) return assembled;
1288
- if (!terminalSupportsHyperlinks()) return assembled;
1289
- let parsed;
1290
- try { parsed = new URL(promo.url); } catch { return assembled; }
1291
- if (parsed.protocol !== 'https:') return assembled;
1292
- if (!PROMO_LINK_HOSTS.has(parsed.hostname)) return assembled;
1293
- const ESC = '';
1294
- return ESC + ']8;;' + parsed.href + ESC + '\\' + assembled + ESC + ']8;;' + ESC + '\\';
1295
- };
1296
- // Visual styling stays per-part. We only add the OSC 8 wrap around the
1297
- // combined string, so the whole row is one click target.
1298
- const labelStyled = promo.url ? UL_ON + label + UL_OFF : label;
1299
- if (!command) return wrapWholeRowInHyperlink(labelStyled + visibleUrlHint);
1300
- return wrapWholeRowInHyperlink(
1301
- labelStyled + visibleUrlHint
1302
- + DIM_ON + manageWord + DIM_OFF
1303
- + BOLD_ON + FG_BRIGHT_WHITE + command + FG_DEFAULT + BOLD_OFF
1304
- );
1305
- } catch (e) {
1306
- return null; // the promo row must never break the statusline
1307
- }
1308
- }
1309
-
1310
- // JSON output — delegates to CLI for accuracy; caller can use --json flag
1311
- function generateJSON() {
1312
- const d = getStatuslineData();
1313
- const git = getGitInfo();
1314
- return Object.assign({}, d, {
1315
- user: Object.assign({ name: git.name, gitBranch: git.gitBranch }, d.user || {}),
1316
- git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
1317
- lastUpdated: new Date().toISOString(),
1318
- });
1319
- }
1320
-
1321
- // ─── Main ───────────────────────────────────────────────────────
1322
- if (process.argv.includes('--json')) {
1323
- console.log(JSON.stringify(generateJSON(), null, 2));
1324
- } else if (process.argv.includes('--compact')) {
1325
- console.log(JSON.stringify(generateJSON()));
1326
- } else {
1327
- console.log(generateStatusline());
1328
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RuFlo V3 Statusline — delegation build (#2195)
4
+ *
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.
8
+ *
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.
12
+ *
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/).
15
+ *
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]
20
+ */
21
+
22
+ /* eslint-disable @typescript-eslint/no-var-requires */
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const { execSync } = require('child_process');
26
+ const os = require('os');
27
+
28
+ // Configuration
29
+ const CONFIG = {
30
+ maxAgents: 15,
31
+ // Header identity defaults to project/repository name. Set `author` to
32
+ // retain the previous `git config user.name` display (#2682).
33
+ identityMode: (process.env.RUFLO_STATUSLINE_IDENTITY || 'project').toLowerCase(),
34
+ // Session-cost display. Claude Code's cost.total_cost_usd is a client-side
35
+ // estimate that "may differ from your actual bill" and reads as misleading on
36
+ // subscription plans, where token usage is not billed per dollar. These let
37
+ // each user pick what the segment means to them without changing the default.
38
+ // RUFLO_STATUSLINE_COST_SYMBOL override the leading '$' (e.g. ⚡, €, 🌱);
39
+ // set to an empty string for the number alone.
40
+ // RUFLO_STATUSLINE_HIDE_COST 1/true/yes/on removes the segment entirely.
41
+ costSymbol: process.env.RUFLO_STATUSLINE_COST_SYMBOL ?? '$',
42
+ hideCost: /^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_HIDE_COST || ''),
43
+ };
44
+
45
+ const CWD = process.cwd();
46
+ // The helper's own install root: the project for a project-local helper,
47
+ // $HOME for a home-level one. The CLI is resolved from here, never from CWD,
48
+ // so a home-level statusline does not run code the opened project supplies.
49
+ const HELPER_ROOT = path.resolve(__dirname, '..', '..');
50
+
51
+ // ─── Delegation cache ───────────────────────────────────────────
52
+ // Cache the CLI JSON result so rapid prompt re-renders (Claude Code
53
+ // refreshes the statusline several times a second while streaming) don't
54
+ // re-invoke the CLI each time.
55
+ // #2337 bumped 10s → 60s.
56
+ // Followup for anthropics/claude-code#70200 (Windows console-flash bug —
57
+ // claude.exe spawns hook/statusline subprocesses without CREATE_NO_WINDOW,
58
+ // producing a visible cmd flash on every render): bumped 60s → 300s to
59
+ // reduce the flash rate 5x on Windows until the upstream fix ships.
60
+ // Tradeoff: stat/git counters update every 5min instead of every 1min;
61
+ // promo/insight row still rotates on its own tighter 20s promoFresh clock.
62
+ const CACHE_FILE = path.join(os.tmpdir(), 'ruflo-statusline-cache-' + require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) + '.json');
63
+ const CACHE_TTL_MS = 300000;
64
+
65
+ // The promo/insight row is designed to rotate on a 20s cadence (funnel/
66
+ // rotation.ts's ROTATION_SLOT_MS / funnel/promo.ts's insight-slot check —
67
+ // duplicated here as a bare number since this generated script has no
68
+ // runtime import of the funnel module; keep in sync if that constant ever
69
+ // changes). The rotation slot is only ever (re)computed SERVER-SIDE inside
70
+ // the CLI subprocess this file shells out to — so a general 60s data cache
71
+ // (correct and necessary for #2337) silently made that 20s design
72
+ // unreachable: cache.fresh stayed true across 2-3 whole rotation slots,
73
+ // so the row visibly "didn't rotate" (user report). Fix: track promo
74
+ // freshness on its OWN, tighter clock — when it lags behind the current
75
+ // slot, fall through to a real CLI call even though the REST of the
76
+ // cached data (security/swarm/system) is still within CACHE_TTL_MS. This
77
+ // does not touch or regress #2337's fix; it only adds a narrower check.
78
+ const PROMO_ROTATION_SLOT_MS = 20000;
79
+
80
+ // Persistent last-known-good promo record. Lives outside the /tmp cache so it
81
+ // survives a full cache wipe / cache write race / CLI failure combo. Written
82
+ // every time we successfully render a promo; read as a last resort so the row
83
+ // never blinks out mid-session (was: 'promo shows then hides' bug report).
84
+ const PROMO_MEMO_FILE = path.join(os.homedir(), '.ruflo', 'statusline-promo.json');
85
+ 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.
86
+
87
+ // #2337: resolve an already-installed @claude-flow/cli (or ruflo) bin so we
88
+ // can invoke it directly via `node`. The previous version called
89
+ // `npx --yes @claude-flow/cli@latest` on every uncached render, which forces
90
+ // a registry resolution + cold-start of the entire CLI per render. With
91
+ // multiple concurrent Claude Code sessions this storms the host (reporter
92
+ // saw load average 40-65 on a 12-core box).
93
+ //
94
+ // Returns EVERY existing bin/cli.js candidate, in preference order (project,
95
+ // monorepo, plugin marketplace, global node_modules including custom-prefix
96
+ // layouts like ~/.npm-global) — mirrors getPkgVersion()'s own path probing.
97
+ //
98
+ // Returns a list, not a single winner: `fs.existsSync` only proves a file is
99
+ // present, not that it actually runs. A marketplace/npx-cached install can
100
+ // exist on disk but be broken (observed in practice: a stale marketplace
101
+ // checkout whose dist/ imports a workspace package, '@claude-flow/cli-core',
102
+ // that isn't bundled there — every invocation throws ERR_MODULE_NOT_FOUND).
103
+ // Picking the first EXISTING path and never falling through meant a single
104
+ // broken install silently killed the promo row for the entire session (the
105
+ // CLI call always failed, so the memo could never refresh and eventually
106
+ // expired). getStatuslineData() now walks this whole list and tries the next
107
+ // candidate on failure, so one broken install can't permanently wedge it.
108
+ function resolveCliBinCandidates() {
109
+ const candidates = [];
110
+ try {
111
+ const home = os.homedir();
112
+ candidates.push(
113
+ path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
114
+ path.join(HELPER_ROOT, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
115
+ path.join(HELPER_ROOT, 'node_modules', 'ruflo', 'bin', 'cli.js'),
116
+ path.join(HELPER_ROOT, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
117
+ );
118
+ try {
119
+ const binDir = path.dirname(process.execPath);
120
+ const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
121
+ for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
122
+ if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
123
+ }
124
+ for (const gm of globalModuleDirs) {
125
+ candidates.push(
126
+ path.join(gm, 'ruflo', 'bin', 'cli.js'),
127
+ path.join(gm, '@claude-flow', 'cli', 'bin', 'cli.js'),
128
+ );
129
+ }
130
+ } catch { /* ignore */ }
131
+ } catch { /* ignore */ }
132
+ return candidates.filter((p) => {
133
+ try {
134
+ if (!fs.existsSync(p)) return false;
135
+ // A candidate's bin/cli.js can exist on disk while its compiled
136
+ // dist/ never got built (Claude Code's own plugin marketplace just
137
+ // git-clones the repo — no install/build step — so every marketplace
138
+ // install is a source-only checkout by construction). Importing
139
+ // dist/src/index.js from bin/cli.js then throws MODULE_NOT_FOUND on
140
+ // every real command; only --version happens to survive it. Check
141
+ // for the compiled entrypoint too so a doomed candidate is skipped
142
+ // up front instead of wasting a spawn-and-fail on every render.
143
+ return fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'));
144
+ } catch { return false; }
145
+ });
146
+ }
147
+
148
+ // Return { fresh, promoFresh, data }. 'fresh' is true only if within the TTL
149
+ // — but data is returned regardless (stale-while-revalidate). This lets us
150
+ // serve last known state (specifically the promo row) when the CLI is
151
+ // slow/unavailable, so users don't see the funnel row flicker in and out on
152
+ // cache expiry. 'promoFresh' is a SEPARATE, tighter check on the same clock
153
+ // as PROMO_ROTATION_SLOT_MS — see that constant's comment for why the promo
154
+ // row needs its own freshness bound distinct from the general 60s TTL.
155
+ function readCache() {
156
+ try {
157
+ if (fs.existsSync(CACHE_FILE)) {
158
+ const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
159
+ if (raw && raw._ts && raw.data) {
160
+ const age = Date.now() - raw._ts;
161
+ return { fresh: age < CACHE_TTL_MS, promoFresh: age < PROMO_ROTATION_SLOT_MS, data: raw.data };
162
+ }
163
+ }
164
+ } catch { /* ignore */ }
165
+ return { fresh: false, promoFresh: false, data: null };
166
+ }
167
+
168
+ function writeCache(data) {
169
+ try { fs.writeFileSync(CACHE_FILE, JSON.stringify({ _ts: Date.now(), data }), 'utf-8'); } catch { /* ignore */ }
170
+ // Also memoize any promo we saw so the row can survive future CLI hiccups.
171
+ try {
172
+ if (data && data.promo && typeof data.promo === 'object') {
173
+ fs.mkdirSync(path.dirname(PROMO_MEMO_FILE), { recursive: true, mode: 0o700 });
174
+ fs.writeFileSync(PROMO_MEMO_FILE, JSON.stringify({ _ts: Date.now(), promo: data.promo }), { encoding: 'utf-8', mode: 0o600 });
175
+ }
176
+ } catch { /* ignore */ }
177
+ }
178
+
179
+ // Last resort: read a memoized promo (up to 6h old). Used when no cache and
180
+ // no CLI response is available — the row still renders, so users don't see
181
+ // the disclosure blink out. Returns null when the memo is absent, expired,
182
+ // or malformed. Never throws.
183
+ function readPromoMemo() {
184
+ try {
185
+ if (!fs.existsSync(PROMO_MEMO_FILE)) return null;
186
+ const raw = JSON.parse(fs.readFileSync(PROMO_MEMO_FILE, 'utf-8'));
187
+ if (raw && raw._ts && (Date.now() - raw._ts) < PROMO_MEMO_TTL_MS && raw.promo) {
188
+ return raw.promo;
189
+ }
190
+ } catch { /* ignore */ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Single source of truth: delegate to the CLI hooks statusline --json command.
196
+ * Falls back to a minimal static object on failure so the statusline still renders.
197
+ *
198
+ * Fix for ruflo#2195: the previous local readers returned 0 for AgentDB patterns
199
+ * (missed the .swarm/memory.db → AgentDB path), computed dddProgress wrong,
200
+ * and only counted ADRs in v3/implementation/adrs/ (missed v3/docs/adr/).
201
+ */
202
+ // Overlay the memoized promo onto any data object that's missing one. This is
203
+ // the safety net that keeps the funnel row rendered when an OLDER cached CLI
204
+ // version is picked up by npx — that older CLI succeeds but omits promo, so
205
+ // the JSON round-trips clean but without our row. We patch it back here.
206
+ function overlayMemoPromo(data) {
207
+ if (data && !data.promo) {
208
+ const memoPromo = readPromoMemo();
209
+ if (memoPromo) data.promo = memoPromo;
210
+ }
211
+ return data;
212
+ }
213
+
214
+ function getStatuslineData() {
215
+ const cache = readCache();
216
+ // Both clocks must be satisfied to skip the CLI call entirely: the general
217
+ // 60s TTL (#2337 — don't re-spawn the CLI on every rapid re-render) AND the
218
+ // tighter promo-rotation clock (this fix — don't let a still-fresh 60s
219
+ // cache silently freeze the promo/insight row across multiple 20s slots).
220
+ if (cache.fresh && cache.promoFresh) {
221
+ return applyLocalOverlays(overlayMemoPromo(cache.data));
222
+ }
223
+
224
+ // #2337: prefer an already-installed CLI bin via direct `node` invocation —
225
+ // no npx, no registry round-trip, no @latest re-resolve per render. Try
226
+ // every candidate that actually EXISTS (not just the first) before falling
227
+ // back to `npx --prefer-offline @claude-flow/cli` (no @latest); an existing
228
+ // but broken install (e.g. a stale marketplace checkout missing a bundled
229
+ // workspace dep) must not block trying the next one.
230
+ //
231
+ // No `2>/dev/null` here (deliberately) — the execSync call below already
232
+ // sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
233
+ // Node level regardless of shell. The redirect was redundant on POSIX and
234
+ // actively broke every candidate on Windows: cmd.exe (execSync's default
235
+ // shell there) doesn't understand /dev/null, so the CLI delegation always
236
+ // failed, silently degrading every render to buildLocalFallback() — 0%
237
+ // intelligence and an empty promo row (the memo cache that keeps the row
238
+ // populated across CLI hiccups is only ever written from a SUCCESSFUL
239
+ // delegation, so it could never get seeded on Windows either).
240
+ // These command lines go through a shell, so a path the shell would interpret
241
+ // (a quote, $, backtick, %, !, newline, or a backslash on POSIX) is never
242
+ // interpolated: the directory name is chosen by whoever made the checkout.
243
+ const shellSafe = (p) => !/["`$%!\r\n]/.test(p) && (process.platform === 'win32' || !p.includes('\\'));
244
+ const cmds = resolveCliBinCandidates()
245
+ .filter((bin) => shellSafe(bin) && shellSafe(process.execPath))
246
+ .map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
247
+ // --prefix: npx resolves the package from HELPER_ROOT, not the project.
248
+ .concat(shellSafe(HELPER_ROOT) ? ['npx --prefer-offline --prefix "' + HELPER_ROOT + '" @claude-flow/cli hooks statusline --json'] : []);
249
+ for (const cmd of cmds) {
250
+ try {
251
+ const raw = execSync(
252
+ cmd,
253
+ { encoding: 'utf-8', timeout: 8000, stdio: ['pipe', 'pipe', 'pipe'], cwd: CWD, windowsHide: true }
254
+ ).trim();
255
+ // The CLI may emit preamble lines before the JSON — find the first '{'.
256
+ const jsonStart = raw.indexOf('{');
257
+ if (jsonStart === -1) throw new Error('no JSON in CLI output');
258
+ const data = JSON.parse(raw.slice(jsonStart));
259
+ // Overlay every block the CLI JSON omits (adrs/agentdb/tests/hooks/integration)
260
+ // with real local reads, so those segments reflect actual state instead of 0.
261
+ applyLocalOverlays(data);
262
+ overlayMemoPromo(data);
263
+ writeCache(data);
264
+ return data;
265
+ } catch { /* this candidate unavailable, broken, or timed out — try the next */ }
266
+ }
267
+
268
+ // Stale-while-revalidate: if we have any cached data, keep serving it so the
269
+ // funnel row doesn't flicker on CLI hiccups. Overlay fresh local reads for
270
+ // the segments the CLI JSON doesn't populate; the promo row survives.
271
+ if (cache.data) {
272
+ applyLocalOverlays(cache.data);
273
+ overlayMemoPromo(cache.data);
274
+ return cache.data;
275
+ }
276
+
277
+ // Last resort: local probes + memo. Users still see the funnel row.
278
+ return overlayMemoPromo(buildLocalFallback());
279
+ }
280
+
281
+ // Count ADRs from BOTH known directories (fix for ruflo#2195: old code missed
282
+ // v3/docs/adr/ which holds ADR-088..ADR-137, i.e. 41 of the 128 total ADRs).
283
+ function getLocalADRCount() {
284
+ const adrDirs = [
285
+ path.join(CWD, 'v3', 'implementation', 'adrs'),
286
+ path.join(CWD, 'v3', 'docs', 'adr'),
287
+ path.join(CWD, 'docs', 'adrs'),
288
+ path.join(CWD, '.claude-flow', 'adrs'),
289
+ ];
290
+ let total = 0;
291
+ for (const dir of adrDirs) {
292
+ try {
293
+ if (fs.existsSync(dir)) {
294
+ const files = fs.readdirSync(dir).filter(function(f) {
295
+ return f.endsWith('.md') && (f.startsWith('ADR-') || f.startsWith('adr-') || /^\d{4}-/.test(f));
296
+ });
297
+ total += files.length;
298
+ }
299
+ } catch { /* ignore */ }
300
+ }
301
+ return { count: total, implemented: total, compliance: 0 };
302
+ }
303
+
304
+ // ─── Local overlays for segments the CLI JSON omits ──────────────
305
+ // 'hooks statusline --json' only returns user/v3Progress/security/swarm/system.
306
+ // agentdb/tests/hooks/integration are never populated, so without these overlays
307
+ // they render as a permanent 0. Each reader is cheap and degrades to zeros.
308
+
309
+ // Real AgentDB stats from the local memory DB. Vectors live in .swarm/memory.db
310
+ // (sql.js + HNSW); ruvector.db is an opaque redb store counted only toward size.
311
+ // One read-only sqlite3 query (mode=ro never takes a write lock the daemon owns).
312
+ function getLocalAgentDB() {
313
+ const result = { vectorCount: 0, dbSizeKB: 0, hasHnsw: false };
314
+ try {
315
+ let bytes = 0;
316
+ for (const f of ['.swarm/memory.db', 'ruvector.db']) {
317
+ try { bytes += fs.statSync(path.join(CWD, f)).size; } catch { /* missing */ }
318
+ }
319
+ result.dbSizeKB = Math.round(bytes / 1024);
320
+
321
+ const memDb = path.join(CWD, '.swarm', 'memory.db');
322
+ if (fs.existsSync(memDb)) {
323
+ const Q = String.fromCharCode(34);
324
+ // Two INDEPENDENT statements -- do NOT combine into one. Coupling the
325
+ // vector count with the vector_indexes row count in a single statement
326
+ // meant that on a DB missing the vector_indexes table (older/agentdb-
327
+ // written DBs), the whole statement failed at PREPARE time (SQLite
328
+ // compiles the full SQL before running), so the valid memory_entries
329
+ // count was discarded too and the statusline showed Vectors 0 despite
330
+ // thousands of real vectors. Split so a missing table can only zero the
331
+ // HNSW flag, never the count. The init self-heal provisions the table so
332
+ // the flag recovers on the next ruflo init / MCP start.
333
+ const countSql = Q + 'SELECT COUNT(*) FROM memory_entries WHERE embedding IS NOT NULL;' + Q;
334
+ const vc = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + countSql, 1500);
335
+ if (vc) result.vectorCount = parseInt(vc, 10) || 0;
336
+ // HNSW flag: separate statement. If vector_indexes is absent, sqlite3
337
+ // exits non-zero and safeExec returns empty -- hasHnsw stays false (exact
338
+ // original semantics: at least one index-config row present).
339
+ const hnswSql = Q + 'SELECT COUNT(*) FROM vector_indexes;' + Q;
340
+ const hn = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + hnswSql, 1500);
341
+ if (hn) result.hasHnsw = (parseInt(hn, 10) || 0) > 0;
342
+ }
343
+ } catch { /* ignore */ }
344
+ return result;
345
+ }
346
+
347
+ // Count test files via a bounded directory walk (no file reads).
348
+ function getLocalTests() {
349
+ let testFiles = 0;
350
+ function countTests(dir, depth) {
351
+ if ((depth || 0) > 4) return;
352
+ try {
353
+ if (!fs.existsSync(dir)) return;
354
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
355
+ if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
356
+ countTests(path.join(dir, e.name), (depth || 0) + 1);
357
+ } else if (e.isFile() && (e.name.includes('.test.') || e.name.includes('.spec.') || e.name.startsWith('test_') || e.name.startsWith('spec_'))) {
358
+ testFiles++;
359
+ }
360
+ }
361
+ } catch { /* ignore */ }
362
+ }
363
+ for (const d of ['tests', 'test', '__tests__', 'src', 'v3']) countTests(path.join(CWD, d));
364
+ return { testFiles, testCases: testFiles * 4 };
365
+ }
366
+
367
+ // Count configured hooks from project .claude/settings.json. Claude Code hooks
368
+ // have no enabled/disabled flag, so every configured hook counts as enabled.
369
+ function getLocalHooks() {
370
+ const result = { enabled: 0, total: 0 };
371
+ try {
372
+ const settings = readJSON(path.join(CWD, '.claude', 'settings.json'));
373
+ const hooks = settings && settings.hooks;
374
+ if (hooks && typeof hooks === 'object') {
375
+ let n = 0;
376
+ for (const ev of Object.keys(hooks)) {
377
+ const groups = hooks[ev];
378
+ if (Array.isArray(groups)) {
379
+ for (const g of groups) {
380
+ if (g && Array.isArray(g.hooks)) n += g.hooks.length;
381
+ }
382
+ }
383
+ }
384
+ result.total = n;
385
+ result.enabled = n;
386
+ }
387
+ } catch { /* ignore */ }
388
+ return result;
389
+ }
390
+
391
+ // Best-effort integration block: DB presence + locally-configured stdio MCP
392
+ // servers (project .mcp.json + global ~/.claude.json). Remote connectors are
393
+ // account-managed and not present in local config, so they are not counted.
394
+ function getLocalIntegration() {
395
+ const integration = { mcpServers: { enabled: 0, total: 0 }, hasDatabase: false };
396
+ try {
397
+ for (const f of ['.swarm/memory.db', 'ruvector.db']) {
398
+ if (fs.existsSync(path.join(CWD, f))) { integration.hasDatabase = true; break; }
399
+ }
400
+ const names = new Set();
401
+ const projMcp = readJSON(path.join(CWD, '.mcp.json'));
402
+ if (projMcp && projMcp.mcpServers) for (const k of Object.keys(projMcp.mcpServers)) names.add(k);
403
+ const claudeJson = readJSON(path.join(os.homedir(), '.claude.json'));
404
+ if (claudeJson) {
405
+ if (claudeJson.mcpServers) for (const k of Object.keys(claudeJson.mcpServers)) names.add(k);
406
+ const proj = claudeJson.projects && claudeJson.projects[CWD];
407
+ if (proj && proj.mcpServers && !Array.isArray(proj.mcpServers)) {
408
+ for (const k of Object.keys(proj.mcpServers)) names.add(k);
409
+ }
410
+ }
411
+ integration.mcpServers.total = names.size;
412
+ integration.mcpServers.enabled = names.size;
413
+ } catch { /* ignore */ }
414
+ return integration;
415
+ }
416
+
417
+ // ─── Security freshness overlay (ruvnet/ruflo#2776) ──────────────
418
+ // The shipped CLI producer (dist/src/funnel/local-signals.js getSecurityStatus)
419
+ // only ever emits PENDING / CLEAN / ISSUES — it captures `scannedAt` but never
420
+ // inspects it, so a year-old scan renders 🛡 ✓ forever and the renderer's
421
+ // STALE / IN_PROGRESS branches are unreachable. Worse, when CLI delegation
422
+ // fails, the stale-while-revalidate cache (readCache() below) keeps serving
423
+ // the pre-scan PENDING pill indefinitely, so a user who runs the advertised
424
+ // `ruflo security scan` sees no change — the pill freezes at "scan pending".
425
+ //
426
+ // This overlay recomputes the security block from disk on EVERY render (same
427
+ // pattern as adrs/agentdb/tests/hooks above), which:
428
+ // 1) Makes STALE reachable — when the newest scan is older than
429
+ // RUFLO_SCAN_STALE_HOURS (default 24h — matches the CVE feed refresh
430
+ // cadence), report STALE regardless of what the cached CLI JSON says.
431
+ // 2) Makes IN_PROGRESS reachable — when a `scan-in-progress` marker file
432
+ // exists and is younger than SECURITY_IN_PROGRESS_MAX_MIN (guards against
433
+ // a crashed scan leaving the marker behind).
434
+ // 3) Caps the "scan pending" display window — if PENDING has been shown for
435
+ // >RUFLO_SCAN_PENDING_CAP_MIN (default 30) without a completion write,
436
+ // switch to STALE and stop rendering the yellow indicator. The tracker
437
+ // lives in ~/.ruflo/statusline-scan-pending-since.json, keyed by CWD
438
+ // hash so multiple project checkouts don't collide.
439
+ // 4) Since this runs AFTER readCache() serves stale data, it bypasses the
440
+ // "pill freezes at PENDING" freeze in defect 2 — the overlay reads
441
+ // fresh disk state even when the CLI delegation is broken.
442
+ const SECURITY_STALE_HOURS = Math.max(1, parseInt(process.env.RUFLO_SCAN_STALE_HOURS || '24', 10) || 24);
443
+ const SECURITY_PENDING_CAP_MIN = Math.max(1, parseInt(process.env.RUFLO_SCAN_PENDING_CAP_MIN || '30', 10) || 30);
444
+ const SECURITY_IN_PROGRESS_MAX_MIN = 30; // marker older than this = crashed scan; treat as absent
445
+ const PENDING_TRACK_FILE = path.join(os.homedir(), '.ruflo', 'statusline-scan-pending-since.json');
446
+ const CWD_KEY = require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 12);
447
+
448
+ function readPendingSince() {
449
+ try {
450
+ if (!fs.existsSync(PENDING_TRACK_FILE)) return null;
451
+ const raw = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8'));
452
+ if (raw && typeof raw === 'object' && typeof raw[CWD_KEY] === 'number') return raw[CWD_KEY];
453
+ } catch { /* ignore */ }
454
+ return null;
455
+ }
456
+
457
+ function writePendingSince(ts) {
458
+ try {
459
+ let obj = {};
460
+ if (fs.existsSync(PENDING_TRACK_FILE)) {
461
+ try { obj = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8')) || {}; } catch { obj = {}; }
462
+ }
463
+ if (ts === null) { delete obj[CWD_KEY]; } else { obj[CWD_KEY] = ts; }
464
+ fs.mkdirSync(path.dirname(PENDING_TRACK_FILE), { recursive: true, mode: 0o700 });
465
+ fs.writeFileSync(PENDING_TRACK_FILE, JSON.stringify(obj), { encoding: 'utf-8', mode: 0o600 });
466
+ } catch { /* ignore */ }
467
+ }
468
+
469
+ function getLocalSecurity(cliSecurity) {
470
+ const base = (cliSecurity && typeof cliSecurity === 'object')
471
+ ? Object.assign({}, cliSecurity)
472
+ : { status: 'NONE', findings: 0, cvesFixed: 0, totalCves: 0 };
473
+ base.findings = Math.max(0, base.findings || 0);
474
+
475
+ const scanDir = path.join(CWD, '.claude', 'security-scans');
476
+
477
+ // Detect a live in-progress marker (writer opts-in by writing this file).
478
+ let inProgress = false;
479
+ try {
480
+ const marker = path.join(scanDir, 'scan-in-progress');
481
+ if (fs.existsSync(marker)) {
482
+ const ageMin = (Date.now() - fs.statSync(marker).mtimeMs) / 60000;
483
+ if (ageMin < SECURITY_IN_PROGRESS_MAX_MIN) inProgress = true;
484
+ }
485
+ } catch { /* ignore */ }
486
+
487
+ // Find newest scan-*.json by mtime and read its findings/timestamp.
488
+ let newestPath = null;
489
+ let newestMtime = 0;
490
+ try {
491
+ if (fs.existsSync(scanDir)) {
492
+ for (const name of fs.readdirSync(scanDir)) {
493
+ if (!name.startsWith('scan-') || !name.endsWith('.json')) continue;
494
+ try {
495
+ const st = fs.statSync(path.join(scanDir, name));
496
+ if (st.mtimeMs > newestMtime) { newestMtime = st.mtimeMs; newestPath = path.join(scanDir, name); }
497
+ } catch { /* ignore */ }
498
+ }
499
+ }
500
+ } catch { /* ignore */ }
501
+
502
+ if (newestPath) {
503
+ // We have a scan on disk — the never-scanned pending tracker is no longer
504
+ // relevant. Clear it so a re-created directory can start a fresh window.
505
+ writePendingSince(null);
506
+
507
+ let scannedAtMs = newestMtime;
508
+ let findings = base.findings;
509
+ try {
510
+ const j = JSON.parse(fs.readFileSync(newestPath, 'utf-8'));
511
+ // scannedAt (CLI producer's field name) OR timestamp (writer's field name).
512
+ const isoStr = (j && (j.scannedAt || j.timestamp)) || null;
513
+ if (isoStr) {
514
+ const t = Date.parse(isoStr);
515
+ if (!isNaN(t)) scannedAtMs = t;
516
+ }
517
+ // findings may be a number, an array, or nested in summary.total.
518
+ if (j) {
519
+ if (typeof j.findings === 'number') findings = j.findings;
520
+ else if (Array.isArray(j.findings)) findings = j.findings.length;
521
+ else if (j.summary && typeof j.summary.total === 'number') findings = j.summary.total;
522
+ }
523
+ } catch { /* ignore parse — fall back to mtime + cached findings */ }
524
+
525
+ base.findings = Math.max(0, findings || 0);
526
+ base.scannedAt = new Date(scannedAtMs).toISOString();
527
+
528
+ const ageHours = (Date.now() - scannedAtMs) / 3600000;
529
+ if (ageHours >= SECURITY_STALE_HOURS) {
530
+ // Stale but findings still render red (a year-old ISSUES scan is still bad).
531
+ base.status = 'STALE';
532
+ } else if (inProgress) {
533
+ base.status = 'IN_PROGRESS';
534
+ } else if (base.findings > 0) {
535
+ base.status = 'ISSUES';
536
+ } else {
537
+ base.status = 'CLEAN';
538
+ }
539
+ return base;
540
+ }
541
+
542
+ // No scan file. If a live marker exists, we're mid-scan.
543
+ if (inProgress) {
544
+ base.status = 'IN_PROGRESS';
545
+ // Reset the pending tracker so, if the scan crashes mid-flight, the next
546
+ // render starts a fresh N-minute pending window instead of an already-expired one.
547
+ writePendingSince(null);
548
+ return base;
549
+ }
550
+
551
+ // Truly never-scanned: track how long we've shown PENDING. After the cap,
552
+ // escalate to STALE with the dim/gray glyph so the pill visibly stops
553
+ // shouting for attention — the user has either ignored it for 30 min or
554
+ // the scan is silently failing to write.
555
+ let pendingSince = readPendingSince();
556
+ if (pendingSince === null || typeof pendingSince !== 'number') {
557
+ pendingSince = Date.now();
558
+ writePendingSince(pendingSince);
559
+ }
560
+ const pendingAgeMin = (Date.now() - pendingSince) / 60000;
561
+ base.status = (pendingAgeMin >= SECURITY_PENDING_CAP_MIN) ? 'STALE' : 'PENDING';
562
+ return base;
563
+ }
564
+
565
+ // Cached pattern count. The store is routinely >10MB (14.5MB / 1277 patterns on
566
+ // the machine this was diagnosed on, costing 53ms to read+parse), and the
567
+ // statusline re-renders on every prompt, so the parse is keyed on the file's
568
+ // mtime+size and reused until the store actually changes.
569
+ const PATTERN_COUNT_CACHE = path.join(os.tmpdir(), 'ruflo-statusline-patterns-' + require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) + '.json');
570
+
571
+ function countPatternsCached(file) {
572
+ let stat;
573
+ try { stat = fs.statSync(file); } catch { return 0; }
574
+ const key = stat.mtimeMs + ':' + stat.size;
575
+
576
+ try {
577
+ const memo = JSON.parse(fs.readFileSync(PATTERN_COUNT_CACHE, 'utf-8'));
578
+ if (memo && memo[file] && memo[file].key === key) return memo[file].count;
579
+ } catch { /* no memo yet, or unreadable — fall through and recount */ }
580
+
581
+ let count = 0;
582
+ try {
583
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
584
+ if (Array.isArray(raw)) count = raw.length;
585
+ else if (raw && Array.isArray(raw.patterns)) count = raw.patterns.length;
586
+ else if (raw && typeof raw === 'object') count = Object.keys(raw).length;
587
+ } catch { return 0; }
588
+
589
+ try {
590
+ let memo = {};
591
+ try { memo = JSON.parse(fs.readFileSync(PATTERN_COUNT_CACHE, 'utf-8')) || {}; } catch { /* start fresh */ }
592
+ memo[file] = { key, count };
593
+ fs.writeFileSync(PATTERN_COUNT_CACHE, JSON.stringify(memo));
594
+ } catch { /* cache is an optimisation, never a requirement */ }
595
+
596
+ return count;
597
+ }
598
+
599
+ // `system` is nominally CLI-populated, but the pattern store is on disk, so a
600
+ // dead CLI must not render as "no learning". Without this, every candidate in
601
+ // resolveCliBinCandidates() failing (a source-only marketplace checkout, or an
602
+ // npx fetch that dies in a workspace root) silently degraded the brain to a
603
+ // hardcoded 0% while the overlaid segments beside it kept showing live data —
604
+ // indistinguishable from a genuine zero. Same reasoning as getLocalAgentDB(),
605
+ // which had to start reading locally after the statusline reported "Vectors 0"
606
+ // on a database holding thousands.
607
+ function getLocalIntelligence(current) {
608
+ const base = (current && typeof current === 'object') ? current : {};
609
+ if (typeof base.intelligencePct === 'number' && base.intelligencePct > 0) return base;
610
+
611
+ for (const file of [
612
+ path.join(CWD, '.claude-flow', 'neural', 'patterns.json'),
613
+ path.join(os.homedir(), '.claude-flow', 'neural', 'patterns.json'),
614
+ ]) {
615
+ const n = countPatternsCached(file);
616
+ if (n > 0) {
617
+ base.intelligencePct = Math.min(100, Math.floor(n / 10));
618
+ return base;
619
+ }
620
+ }
621
+
622
+ // No CLI value and no local store: unknown, which is not the same as zero.
623
+ base.intelligencePct = null;
624
+ return base;
625
+ }
626
+
627
+ // Overlay every locally-derived block onto the CLI data (mutates in place).
628
+ function applyLocalOverlays(data) {
629
+ data.adrs = getLocalADRCount();
630
+ data.agentdb = getLocalAgentDB();
631
+ data.tests = getLocalTests();
632
+ data.hooks = getLocalHooks();
633
+ data.integration = getLocalIntegration();
634
+ // Security overlay: recompute freshness from disk on every render so cached
635
+ // CLI JSON can never freeze the pill at PENDING. See getLocalSecurity() above.
636
+ data.security = getLocalSecurity(data.security);
637
+ data.system = getLocalIntelligence(data.system);
638
+ return data;
639
+ }
640
+
641
+ // Minimal local fallback when the CLI is not installed or times out.
642
+ // Returns a structure that matches the CLI JSON schema so the renderer works.
643
+ function buildLocalFallback() {
644
+ const memMB = Math.floor(process.memoryUsage().heapUsed / 1024 / 1024);
645
+
646
+ return applyLocalOverlays({
647
+ user: { name: 'user', gitBranch: '', modelName: 'Claude Code' },
648
+ v3Progress: { domainsCompleted: 0, totalDomains: 5, dddProgress: 0, patternsLearned: 0, sessionsCompleted: 0 },
649
+ security: { status: 'NONE', findings: 0, cvesFixed: 0, totalCves: 0 },
650
+ swarm: { activeAgents: 0, maxAgents: CONFIG.maxAgents, coordinationActive: false },
651
+ system: { memoryMB: memMB, contextPct: 0, intelligencePct: null, subAgents: 0 },
652
+ lastUpdated: new Date().toISOString(),
653
+ });
654
+ }
655
+
656
+ // ANSI colors
657
+ const c = {
658
+ reset: '\x1b[0m',
659
+ bold: '\x1b[1m',
660
+ dim: '\x1b[2m',
661
+ red: '\x1b[0;31m',
662
+ green: '\x1b[0;32m',
663
+ yellow: '\x1b[0;33m',
664
+ blue: '\x1b[0;34m',
665
+ purple: '\x1b[0;35m',
666
+ cyan: '\x1b[0;36m',
667
+ brightRed: '\x1b[1;31m',
668
+ brightGreen: '\x1b[1;32m',
669
+ brightYellow: '\x1b[1;33m',
670
+ brightBlue: '\x1b[1;34m',
671
+ brightPurple: '\x1b[1;35m',
672
+ brightCyan: '\x1b[1;36m',
673
+ brightWhite: '\x1b[1;37m',
674
+ };
675
+
676
+ // Safe execSync with strict timeout (returns empty string on failure)
677
+ function safeExec(cmd, timeoutMs) {
678
+ try {
679
+ return execSync(cmd, {
680
+ encoding: 'utf-8',
681
+ timeout: timeoutMs || 2000,
682
+ stdio: ['pipe', 'pipe', 'pipe'],
683
+ // Windows: without this, every execSync spawns cmd.exe /d /s /c which
684
+ // flashes a visible console window every render (~1/min via the 60s
685
+ // cache TTL). windowsHide runs the child in a hidden window instead.
686
+ // No-op on POSIX. Fix for #2XXX (user report: "cmd prompt keeps opening").
687
+ windowsHide: true,
688
+ }).trim();
689
+ } catch {
690
+ return '';
691
+ }
692
+ }
693
+
694
+ // Safe JSON file reader (returns null on failure)
695
+ function readJSON(filePath) {
696
+ try {
697
+ if (fs.existsSync(filePath)) {
698
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
699
+ }
700
+ } catch { /* ignore */ }
701
+ return null;
702
+ }
703
+
704
+ // ─── Git info (pure-Node / single exec — needed for branch display) ──────────
705
+
706
+ // #3045 — getGitInfo() shells out to a 5-command chain per render. Uncached,
707
+ // it queues subprocesses faster than they finish on large repos with multiple
708
+ // concurrent Claude Code sessions, producing the pileup reported in that
709
+ // issue (hundreds of orphan git children, load-avg in the hundreds). Cache
710
+ // the parsed result in a per-cwd tmp file with a short TTL — dirty/branch
711
+ // status doesn't need per-render freshness, and 5s is short enough to feel
712
+ // live.
713
+ const GIT_INFO_CACHE_FILE = path.join(
714
+ os.tmpdir(),
715
+ 'ruflo-statusline-gitinfo-' +
716
+ require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) +
717
+ '.json'
718
+ );
719
+ const GIT_INFO_TTL_MS = 5000;
720
+
721
+ function readGitInfoCache() {
722
+ try {
723
+ if (fs.existsSync(GIT_INFO_CACHE_FILE)) {
724
+ const raw = JSON.parse(fs.readFileSync(GIT_INFO_CACHE_FILE, 'utf-8'));
725
+ if (raw && typeof raw._ts === 'number' && Date.now() - raw._ts < GIT_INFO_TTL_MS && raw.data) {
726
+ return raw.data;
727
+ }
728
+ }
729
+ } catch { /* ignore */ }
730
+ return null;
731
+ }
732
+
733
+ function writeGitInfoCache(data) {
734
+ try {
735
+ fs.writeFileSync(
736
+ GIT_INFO_CACHE_FILE,
737
+ JSON.stringify({ _ts: Date.now(), data }),
738
+ 'utf-8'
739
+ );
740
+ } catch { /* ignore */ }
741
+ }
742
+
743
+ function getGitInfo() {
744
+ const cached = readGitInfoCache();
745
+ if (cached) return cached;
746
+
747
+ const result = {
748
+ name: path.basename(CWD) || 'project', gitBranch: '', modified: 0, untracked: 0,
749
+ staged: 0, ahead: 0, behind: 0,
750
+ };
751
+
752
+ const script = [
753
+ 'git rev-parse --show-toplevel 2>/dev/null || pwd',
754
+ 'echo "---SEP---"',
755
+ 'git config user.name 2>/dev/null || echo user',
756
+ 'echo "---SEP---"',
757
+ 'git branch --show-current 2>/dev/null',
758
+ 'echo "---SEP---"',
759
+ 'git status --porcelain 2>/dev/null',
760
+ 'echo "---SEP---"',
761
+ 'git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"',
762
+ ].join('; ');
763
+
764
+ const raw = safeExec("sh -c '" + script + "'", 3000);
765
+ if (!raw) {
766
+ // Cache even the empty result — if git is slow/unavailable, we don't want
767
+ // to re-attempt on every render either.
768
+ writeGitInfoCache(result);
769
+ return result;
770
+ }
771
+
772
+ const parts = raw.split('---SEP---').map(function(s) { return s.trim(); });
773
+ if (parts.length >= 5) {
774
+ const projectName = path.basename(parts[0] || CWD) || path.basename(CWD) || 'project';
775
+ const authorName = parts[1] || 'user';
776
+ result.name = CONFIG.identityMode === 'author' ? authorName : projectName;
777
+ result.gitBranch = parts[2] || '';
778
+
779
+ if (parts[3]) {
780
+ for (const line of parts[3].split('\n')) {
781
+ if (!line || line.length < 2) continue;
782
+ const x = line[0], y = line[1];
783
+ if (x === '?' && y === '?') { result.untracked++; continue; }
784
+ if (x !== ' ' && x !== '?') result.staged++;
785
+ if (y !== ' ' && y !== '?') result.modified++;
786
+ }
787
+ }
788
+
789
+ const ab = (parts[4] || '0 0').split(/\s+/);
790
+ result.ahead = parseInt(ab[0]) || 0;
791
+ result.behind = parseInt(ab[1]) || 0;
792
+ }
793
+
794
+ writeGitInfoCache(result);
795
+ return result;
796
+ }
797
+
798
+ // Detect model name from Claude config (pure file reads, no exec)
799
+ function getModelName() {
800
+ try {
801
+ const claudeConfig = readJSON(path.join(os.homedir(), '.claude.json'));
802
+ if (claudeConfig && claudeConfig.projects) {
803
+ for (const [projectPath, projectConfig] of Object.entries(claudeConfig.projects)) {
804
+ if (CWD === projectPath || CWD.startsWith(projectPath + '/')) {
805
+ const usage = projectConfig.lastModelUsage;
806
+ if (usage) {
807
+ const ids = Object.keys(usage);
808
+ if (ids.length > 0) {
809
+ let modelId = ids[ids.length - 1];
810
+ let latest = 0;
811
+ for (const id of ids) {
812
+ const ts = usage[id] && usage[id].lastUsedAt ? new Date(usage[id].lastUsedAt).getTime() : 0;
813
+ if (ts > latest) { latest = ts; modelId = id; }
814
+ }
815
+ if (modelId.includes('opus')) return 'Opus 4.8';
816
+ if (modelId.includes('sonnet')) return 'Sonnet 4.6';
817
+ if (modelId.includes('haiku')) return 'Haiku 4.5';
818
+ return modelId.split('-').slice(1, 3).join(' ');
819
+ }
820
+ }
821
+ break;
822
+ }
823
+ }
824
+ }
825
+ } catch { /* ignore */ }
826
+
827
+ // Fallback: settings.json model field
828
+ const settings = getSettings();
829
+ if (settings && settings.model) {
830
+ const m = settings.model;
831
+ if (m.includes('opus')) return 'Opus 4.8';
832
+ if (m.includes('sonnet')) return 'Sonnet 4.6';
833
+ if (m.includes('haiku')) return 'Haiku 4.5';
834
+ }
835
+ return 'Claude Code';
836
+ }
837
+
838
+ // ─── Stdin reader (Claude Code pipes session JSON) ──────────────
839
+ // Claude Code sends session JSON via stdin. Read synchronously so the
840
+ // script works both when invoked by Claude Code (stdin has JSON) and
841
+ // when run manually from terminal (stdin is empty/tty).
842
+ let _stdinData = null;
843
+ function getStdinData() {
844
+ if (_stdinData !== undefined && _stdinData !== null) return _stdinData;
845
+ try {
846
+ if (process.stdin.isTTY) { _stdinData = null; return null; }
847
+ const chunks = [];
848
+ const buf = Buffer.alloc(4096);
849
+ let bytesRead;
850
+ try {
851
+ while ((bytesRead = fs.readSync(0, buf, 0, buf.length, null)) > 0) {
852
+ chunks.push(buf.slice(0, bytesRead));
853
+ }
854
+ } catch { /* EOF or read error */ }
855
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
856
+ _stdinData = (raw && raw.startsWith('{')) ? JSON.parse(raw) : null;
857
+ } catch {
858
+ _stdinData = null;
859
+ }
860
+ return _stdinData;
861
+ }
862
+
863
+ function getModelFromStdin() {
864
+ const data = getStdinData();
865
+ return (data && data.model && data.model.display_name) ? data.model.display_name : null;
866
+ }
867
+
868
+ function getContextFromStdin() {
869
+ const data = getStdinData();
870
+ if (data && data.context_window) {
871
+ return { usedPct: Math.floor(data.context_window.used_percentage || 0) };
872
+ }
873
+ return null;
874
+ }
875
+
876
+ function getCostFromStdin() {
877
+ const data = getStdinData();
878
+ if (data && data.cost) {
879
+ const durationMs = data.cost.total_duration_ms || 0;
880
+ const mins = Math.floor(durationMs / 60000);
881
+ const secs = Math.floor((durationMs % 60000) / 1000);
882
+ return {
883
+ costUsd: data.cost.total_cost_usd || 0,
884
+ duration: mins > 0 ? mins + 'm' + secs + 's' : secs + 's',
885
+ };
886
+ }
887
+ return null;
888
+ }
889
+
890
+ // Compares dotted-numeric version strings (e.g. "3.27.1" vs "3.27.10").
891
+ // Returns >0 if a>b, <0 if a<b, 0 if equal-as-far-as-parseable. Deliberately
892
+ // simple (no prerelease/build-metadata handling) — this only orders local
893
+ // package.json versions against each other, never anything untrusted from
894
+ // a payload, so a full semver implementation would be dead weight here.
895
+ function compareVersions(a, b) {
896
+ const pa = String(a).split('.').map((n) => parseInt(n, 10));
897
+ const pb = String(b).split('.').map((n) => parseInt(n, 10));
898
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
899
+ const na = Number.isFinite(pa[i]) ? pa[i] : 0;
900
+ const nb = Number.isFinite(pb[i]) ? pb[i] : 0;
901
+ if (na !== nb) return na - nb;
902
+ }
903
+ return 0;
904
+ }
905
+
906
+ // #2742: when CWD is a linked git worktree, it has no node_modules of its
907
+ // own (worktrees don't get their own `npm install`), so every CWD-relative
908
+ // probe in getPkgVersion() misses and the version silently falls back to
909
+ // the baked-in default — even though the main repo's install a few
910
+ // directories away is perfectly resolvable. A linked worktree's `.git` is
911
+ // a plain FILE (not a directory) containing `gitdir: <main>/.git/worktrees/
912
+ // <name>`; walk up from CWD to find it, parse the pointer, and strip the
913
+ // trailing `.git/worktrees/<name>` segment to recover the main repo root.
914
+ // Pure fs — no `git rev-parse` spawn (statusline renders are latency-
915
+ // sensitive; this doc comment's neighbors are explicit about avoiding
916
+ // spawns in the render path).
917
+ function resolveWorktreeMainRoot() {
918
+ try {
919
+ let dir = CWD;
920
+ for (;;) {
921
+ const dotGit = path.join(dir, '.git');
922
+ if (fs.existsSync(dotGit)) {
923
+ if (fs.statSync(dotGit).isFile()) {
924
+ const contents = fs.readFileSync(dotGit, 'utf-8');
925
+ const m = contents.match(/^gitdir:\s*(.+)$/m);
926
+ const wtGitDir = m && m[1].trim();
927
+ if (wtGitDir) {
928
+ // Git writes this pointer with forward slashes even on Windows
929
+ // (a git-for-windows convention for its own internal files) —
930
+ // path.sep (backslash on win32) never matches, so normalize
931
+ // before searching rather than building an OS-specific marker.
932
+ const normalized = wtGitDir.replace(/\\/g, '/');
933
+ const marker = '/.git/worktrees/';
934
+ const idx = normalized.lastIndexOf(marker);
935
+ if (idx > 0) return normalized.slice(0, idx);
936
+ }
937
+ }
938
+ return null; // a real (non-worktree) .git dir — nothing to resolve
939
+ }
940
+ const parent = path.dirname(dir);
941
+ if (parent === dir) return null; // reached filesystem root
942
+ dir = parent;
943
+ }
944
+ } catch {
945
+ return null;
946
+ }
947
+ }
948
+
949
+ function getPkgVersion() {
950
+ // Baked in at generation time from the real running CLI's own resolved
951
+ // version (see generateStatuslineScript()'s doc comment) — correct even
952
+ // when this renders via a pure npx invocation with no local install for
953
+ // the candidate scan below to find.
954
+ let ver = "3.32.8";
955
+ try {
956
+ const home = os.homedir();
957
+ const pkgPaths = [
958
+ path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'package.json'),
959
+ path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'package.json'),
960
+ path.join(CWD, 'node_modules', 'ruflo', 'package.json'),
961
+ path.join(CWD, 'v3', '@claude-flow', 'cli', 'package.json'),
962
+ ];
963
+ // #2742: CWD is a linked git worktree with no node_modules of its own —
964
+ // probe the main repo's install too, so a worktree session shows the
965
+ // same version a main-repo session would.
966
+ const worktreeMainRoot = resolveWorktreeMainRoot();
967
+ if (worktreeMainRoot) {
968
+ pkgPaths.push(
969
+ path.join(worktreeMainRoot, 'node_modules', '@claude-flow', 'cli', 'package.json'),
970
+ path.join(worktreeMainRoot, 'node_modules', 'ruflo', 'package.json'),
971
+ path.join(worktreeMainRoot, 'v3', '@claude-flow', 'cli', 'package.json'),
972
+ );
973
+ }
974
+ // #2221: global installs (npm i -g ruflo) live outside CWD/node_modules, so the
975
+ // probes above all miss and the version falls back to the hard-coded default.
976
+ // Derive the global node_modules dir from the running node binary (no npm spawn —
977
+ // statusline renders often). Covers nvm/mise (bin/../lib/node_modules) and Windows
978
+ // (bin/node_modules) layouts.
979
+ try {
980
+ const binDir = path.dirname(process.execPath);
981
+ const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
982
+ // #2221 follow-up: a custom npm prefix (e.g. ~/.npm-global) is decoupled from
983
+ // the node binary location, so the binDir-derived probes above all miss. Also
984
+ // probe the npm prefix from the environment and the common ~/.npm-global default.
985
+ for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
986
+ if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
987
+ }
988
+ for (const gm of globalModuleDirs) {
989
+ pkgPaths.push(
990
+ path.join(gm, 'ruflo', 'package.json'),
991
+ path.join(gm, '@claude-flow', 'cli', 'package.json'),
992
+ );
993
+ }
994
+ } catch { /* ignore */ }
995
+ // Pick the HIGHEST version among every candidate that exists, not the
996
+ // first one found. The marketplace plugin path is probed first (list
997
+ // order above), but Claude Code's own plugin marketplace mechanism
998
+ // syncs on its own git-pull cadence, independent of npm publishes — a
999
+ // freshly-published npm version can sit alongside a stale marketplace
1000
+ // checkout for a while (observed live: marketplace one release behind
1001
+ // right after a publish). Taking the first EXISTING candidate meant the
1002
+ // header could show a stale version even when a newer install (e.g.
1003
+ // node_modules/@claude-flow/cli from a plain npm install) was sitting right there.
1004
+ let found = false;
1005
+ for (const p of pkgPaths) {
1006
+ if (!fs.existsSync(p)) continue;
1007
+ try {
1008
+ const pkg = JSON.parse(fs.readFileSync(p, 'utf-8'));
1009
+ if (pkg && typeof pkg.version === 'string' && pkg.version.length > 0) {
1010
+ if (!found || compareVersions(pkg.version, ver) > 0) ver = pkg.version;
1011
+ found = true;
1012
+ }
1013
+ } catch { /* ignore */ }
1014
+ }
1015
+ } catch { /* ignore */ }
1016
+ return ver;
1017
+ }
1018
+
1019
+ // ─── Rendering ──────────────────────────────────────────────────
1020
+
1021
+ function progressBar(current, total) {
1022
+ const width = 5;
1023
+ const filled = Math.round((current / total) * width);
1024
+ return '[' + '●'.repeat(filled) + '○'.repeat(width - filled) + ']';
1025
+ }
1026
+
1027
+ function generateStatusline() {
1028
+ const d = getStatuslineData();
1029
+ const git = getGitInfo();
1030
+ const modelName = getModelFromStdin() || (d.user && d.user.modelName) || 'Claude Code';
1031
+ const ctxInfo = getContextFromStdin();
1032
+ const costInfo = getCostFromStdin();
1033
+ // Named RUFLO_VERSION (not pkgVersion) so the #1951 regression guard
1034
+ // (scripts/audit-fix-invariants.mjs) can pin its presence in the emitted
1035
+ // .cjs artifact — without it the header silently reverts to a hard-coded
1036
+ // "RuFlo V3.5" for anyone whose install doesn't match the first probe path.
1037
+ const RUFLO_VERSION = getPkgVersion();
1038
+
1039
+ const progress = d.v3Progress || {};
1040
+ const security = d.security || {};
1041
+ const swarm = d.swarm || {};
1042
+ const system = d.system || {};
1043
+ const adrs = d.adrs || {};
1044
+ const hooks = d.hooks || {};
1045
+ const agentdb = d.agentdb || {};
1046
+ const tests = d.tests || {};
1047
+
1048
+ const domainsCompleted = progress.domainsCompleted || 0;
1049
+ const totalDomains = progress.totalDomains || 5;
1050
+ const dddProgress = progress.dddProgress || 0;
1051
+ const patternsLearned = progress.patternsLearned || 0;
1052
+ const activeAgents = swarm.activeAgents || 0;
1053
+ const maxAgents = swarm.maxAgents || CONFIG.maxAgents;
1054
+ const coordinationActive = swarm.coordinationActive || false;
1055
+ // null/undefined means "no source answered", which must not collapse to 0 --
1056
+ // that is exactly the ambiguity this segment used to present.
1057
+ const intelligencePct = (typeof system.intelligencePct === 'number')
1058
+ ? system.intelligencePct
1059
+ : null;
1060
+ const memoryMB = system.memoryMB || 0;
1061
+ const subAgents = system.subAgents || 0;
1062
+ const findings = Math.max(0, security.findings || 0);
1063
+ const secStatus = security.status || 'NONE';
1064
+ const adrCount = adrs.count || 0;
1065
+ const adrImpl = adrs.implemented || 0;
1066
+ const hooksEnabled = hooks.enabled || 0;
1067
+ const hooksTotal = hooks.total || 0;
1068
+ const vectorCount = agentdb.vectorCount || 0;
1069
+ const hasHnsw = agentdb.hasHnsw || false;
1070
+ const dbSizeKB = agentdb.dbSizeKB || 0;
1071
+ const testFiles = tests.testFiles || 0;
1072
+ const testCases = tests.testCases || testFiles * 4;
1073
+
1074
+ const lines = [];
1075
+
1076
+ // 3-line design (fits Claude Code's visible statusline area — line 4+ gets
1077
+ // replaced by the system guidance / input prompt line):
1078
+ // Line 1 — Header (RuFlo version · git · model · timing · context · cost)
1079
+ // Line 2 — Compressed ops (Swarm · Hooks · 🧠 · 💾 · Health)
1080
+ // Line 3 — Promo / disclosure row (funnel surface, ADR-301)
1081
+
1082
+ // ─── Line 1: header ────────────────────────────────────────────
1083
+ let header = c.bold + c.brightPurple + '▊ RuFlo V' + RUFLO_VERSION + ' ' + c.reset;
1084
+ header += (coordinationActive ? c.brightCyan : c.dim) + '● ' + c.brightCyan + git.name + c.reset;
1085
+ if (git.gitBranch) {
1086
+ header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightBlue + '⏇ ' + git.gitBranch + c.reset;
1087
+ const changes = git.modified + git.staged + git.untracked;
1088
+ if (changes > 0) {
1089
+ let ind = '';
1090
+ if (git.staged > 0) ind += c.brightGreen + '+' + git.staged + c.reset;
1091
+ if (git.modified > 0) ind += c.brightYellow + '~' + git.modified + c.reset;
1092
+ if (git.untracked > 0) ind += c.dim + '?' + git.untracked + c.reset;
1093
+ header += ' ' + ind;
1094
+ }
1095
+ if (git.ahead > 0) header += ' ' + c.brightGreen + '↑' + git.ahead + c.reset;
1096
+ if (git.behind > 0) header += ' ' + c.brightRed + '↓' + git.behind + c.reset;
1097
+ }
1098
+ header += ' ' + c.dim + '│' + c.reset + ' ' + c.purple + modelName + c.reset;
1099
+ const duration = costInfo ? costInfo.duration : '';
1100
+ if (duration) header += ' ' + c.dim + '│' + c.reset + ' ' + c.cyan + '⏱ ' + duration + c.reset;
1101
+ if (ctxInfo && ctxInfo.usedPct > 0) {
1102
+ const ctxColor = ctxInfo.usedPct >= 90 ? c.brightRed : ctxInfo.usedPct >= 70 ? c.brightYellow : c.brightGreen;
1103
+ header += ' ' + c.dim + '│' + c.reset + ' ' + ctxColor + '● ' + ctxInfo.usedPct + '% ctx' + c.reset;
1104
+ }
1105
+ if (!CONFIG.hideCost && costInfo && costInfo.costUsd > 0) {
1106
+ header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightYellow + CONFIG.costSymbol + costInfo.costUsd.toFixed(2) + c.reset;
1107
+ }
1108
+ lines.push(header);
1109
+
1110
+ // ─── Line 2: compressed ops ────────────────────────────────────
1111
+ // Everything actionable in one dense row. Show only what changes what you
1112
+ // do next; diagnostic detail moves to `ruflo status --verbose`.
1113
+ const agentsColor = activeAgents > 0 ? c.brightGreen : c.dim;
1114
+ const hooksColor = hooksEnabled > 0 ? c.brightGreen : c.dim;
1115
+ const intellColor = intelligencePct === null ? c.dim : intelligencePct >= 80 ? c.brightGreen : intelligencePct >= 40 ? c.brightYellow : c.dim;
1116
+ const swarmInd = coordinationActive ? c.brightGreen + '◉' + c.reset + ' ' : c.dim + '○' + c.reset + ' ';
1117
+ const healthAllGreen = (secStatus === 'CLEAN' || secStatus === 'NONE') && findings === 0;
1118
+ const opsParts = [];
1119
+ opsParts.push(c.cyan + 'Swarm ' + swarmInd + agentsColor + activeAgents + c.reset + '/' + c.brightWhite + maxAgents + c.reset);
1120
+ if (subAgents > 0) opsParts.push(c.brightPurple + '👥 ' + subAgents + c.reset);
1121
+ opsParts.push(c.cyan + 'Hooks ' + hooksColor + hooksEnabled + c.reset + '/' + c.brightWhite + hooksTotal + c.reset);
1122
+ opsParts.push(intellColor + '🧠 ' + (intelligencePct === null ? '—' : intelligencePct + '%') + c.reset);
1123
+ opsParts.push(c.brightCyan + '💾 ' + memoryMB + 'MB' + c.reset);
1124
+ // Health: one glyph when green, terse copy when there's something to act on.
1125
+ if (healthAllGreen) {
1126
+ opsParts.push(c.brightGreen + '🛡 ✓' + c.reset);
1127
+ } else {
1128
+ // #2776: STALE gets dim/gray (distinct from the actionable yellow of
1129
+ // PENDING/IN_PROGRESS) so a stale pill visibly stops shouting for
1130
+ // attention — the user can act on the "run ruflo security scan" prompt or
1131
+ // ignore it without a permanently-yellow indicator.
1132
+ if (secStatus === 'PENDING') opsParts.push(c.brightYellow + '🛡 scan pending' + c.reset);
1133
+ else if (secStatus === 'IN_PROGRESS') opsParts.push(c.brightYellow + '🛡 scanning…' + c.reset);
1134
+ else if (secStatus === 'ISSUES') opsParts.push(c.brightRed + '🛡 findings' + c.reset);
1135
+ else if (secStatus === 'STALE') opsParts.push(c.dim + '🛡 scan stale' + c.reset);
1136
+ else if (secStatus !== 'NONE' && secStatus !== 'CLEAN') opsParts.push(c.brightRed + '🛡 ' + secStatus.toLowerCase() + c.reset);
1137
+ if (findings > 0) {
1138
+ opsParts.push(c.brightRed + '⚠ ' + findings + ' finding' + (findings === 1 ? '' : 's') + c.reset);
1139
+ }
1140
+ }
1141
+ lines.push(opsParts.join(' ' + c.dim + '·' + c.reset + ' '));
1142
+
1143
+ // ─── Line 3: promo / disclosure / insight ───────────────────────
1144
+ // Colored by content kind so it reads as *what it is*, not as noise:
1145
+ // disclosure → brightCyan (announcement / capability link)
1146
+ // promotional → brightPurple (Cognitum sponsor spot)
1147
+ // educational → yellow (a tip)
1148
+ // insight → brightRed (environment/task-aware, local, actionable —
1149
+ // distinct from remote content on purpose)
1150
+ const promoRow = getPromoRow(d);
1151
+ if (promoRow) {
1152
+ const kind = (d && d.promo && d.promo.kind) || 'disclosure';
1153
+ const promoColor = kind === 'promotional' ? c.brightPurple
1154
+ : kind === 'educational' ? c.yellow
1155
+ : kind === 'insight' ? c.brightRed
1156
+ : c.brightCyan;
1157
+ lines.push(promoColor + promoRow + c.reset);
1158
+ }
1159
+
1160
+ // Trailing blank line so Claude Code's input prompt gets breathing room
1161
+ // instead of butting directly against the last statusline row.
1162
+ return lines.join('\n') + '\n';
1163
+ }
1164
+
1165
+ // ─── Funnel promo row (ADR-301) ─────────────────────────────────
1166
+ // Allowlist for OSC 8 hyperlink targets. Ships in code (not in payload) so
1167
+ // no message can smuggle a link to an unapproved host.
1168
+ //
1169
+ // The final destination hosts (cognitum.one / agentics.org) AND the
1170
+ // click-redirect host are both allowlisted here: promo.ts routes every
1171
+ // clickable message through the server-side click-redirect (ADR-311 §7)
1172
+ // so promo_open + geo are captured before the 302 to the real target —
1173
+ // so the OSC 8 link the renderer emits points at the redirect host, not
1174
+ // the final destination directly.
1175
+ const PROMO_LINK_HOSTS = new Set([
1176
+ 'cognitum.one', 'www.cognitum.one', 'docs.cognitum.one',
1177
+ // agentics.org — OSS foundation, distinct sponsor domain. Kept in sync
1178
+ // with messages.ts ALLOWED_URL_HOSTS.
1179
+ 'agentics.org', 'www.agentics.org',
1180
+ // Click-redirect host (funnel.ruv.io once its TLS cert is live; the raw
1181
+ // Cloud Run hostname is allowlisted too since event-transport.ts /
1182
+ // message-transport.ts / attribution.ts currently point at it as a TEMP
1183
+ // fallback while the domain mapping's cert provisions).
1184
+ 'funnel.ruv.io',
1185
+ 'cognitum-analytics-63rzcdswba-uc.a.run.app',
1186
+ ]);
1187
+
1188
+ // Emit OSC 8 hyperlinks unless the environment is known-broken. tmux mangles
1189
+ // raw OSC 8 (see anthropics/claude-code#27047) — opt in via env if wrapped.
1190
+ function terminalSupportsHyperlinks() {
1191
+ if (process.env.CI || process.env.GITHUB_ACTIONS) return false;
1192
+ if (process.env.TERM === 'dumb') return false;
1193
+ if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_STATUSLINE_HYPERLINKS || ''))) return false;
1194
+ if (process.env.TMUX && !process.env.RUFLO_STATUSLINE_HYPERLINKS_TMUX) return false;
1195
+ return true;
1196
+ }
1197
+
1198
+ // Wrap a label in an OSC 8 hyperlink escape sequence. Falls back to the raw
1199
+ // label whenever the URL is not an allowlisted https target, when the terminal
1200
+ // can't render hyperlinks, or when parsing fails — a broken link must never
1201
+ // leave a raw URL or stray escape in the statusline output.
1202
+ function safeTerminalLink(label, url) {
1203
+ if (!terminalSupportsHyperlinks()) return label;
1204
+ if (typeof url !== 'string' || url.length === 0) return label;
1205
+ let parsed;
1206
+ try { parsed = new URL(url); } catch { return label; }
1207
+ if (parsed.protocol !== 'https:') return label;
1208
+ if (!PROMO_LINK_HOSTS.has(parsed.hostname)) return label;
1209
+ const cleanLabel = String(label).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '');
1210
+ if (cleanLabel.length === 0) return label;
1211
+ const ESC = '\u001b';
1212
+ return ESC + ']8;;' + parsed.href + ESC + '\\' + cleanLabel + ESC + ']8;;' + ESC + '\\';
1213
+ }
1214
+
1215
+ function getPromoRow(d) {
1216
+ try {
1217
+ if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
1218
+ if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_FUNNEL || ''))) return null;
1219
+ const promo = d && d.promo;
1220
+ if (!promo || typeof promo.text !== 'string') return null;
1221
+ // Strip control chars / ANSI / bidi overrides — promo copy is data and
1222
+ // must never emit its own terminal sequences. Hard-cap length AFTER the
1223
+ // strip; append an ellipsis when the cap fires so the row visibly reads
1224
+ // as truncated instead of chopping a word mid-character (was: silent
1225
+ // slice(0,100) that could produce output that looked like corrupt data).
1226
+ const MAX_LEN = 100;
1227
+ const sanitized = promo.text
1228
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
1229
+ ;
1230
+ const text = (sanitized.length > MAX_LEN ? sanitized.slice(0, MAX_LEN - 1).trimEnd() + '…' : sanitized).trim();
1231
+ if (text.length === 0) return null;
1232
+ // Split the label from the trailing "· manage: ruflo settings" instruction
1233
+ // so each part gets styling that matches what it actually IS:
1234
+ // 1. label — OSC 8 hyperlink + underline. A real clickable link.
1235
+ // 2. "manage:" — dim. Just a connector word, no action implied.
1236
+ // 3. "ruflo settings" — bold/bright, NOT underlined. This is a shell
1237
+ // command the user TYPES, not a link they CLICK — a terminal can
1238
+ // never safely execute a command from a click (that would let any
1239
+ // server-served message run arbitrary commands), so we deliberately
1240
+ // avoid the underline/OSC8 cues that imply "clickable". Bold+bright
1241
+ // instead signals "this is the important bit — copy/type it".
1242
+ // Educational tips have no manage tail and no URL — plain text through.
1243
+ const manageIdx = text.indexOf(' · manage: ');
1244
+ const label = manageIdx > 0 ? text.slice(0, manageIdx) : text;
1245
+ const manageWord = manageIdx > 0 ? ' · manage: ' : '';
1246
+ const command = manageIdx > 0 ? text.slice(manageIdx + manageWord.length) : '';
1247
+ const UL_ON = '\u001b[4m';
1248
+ const UL_OFF = '\u001b[24m';
1249
+ const DIM_ON = '\u001b[2m';
1250
+ const DIM_OFF = '\u001b[22m';
1251
+ const BOLD_ON = '\u001b[1m';
1252
+ const BOLD_OFF = '\u001b[22m';
1253
+ const FG_BRIGHT_WHITE = '';
1254
+ // Reset FG to default so the caller's row-color code resumes coloring the
1255
+ // rest of the row after the command portion. Without this the row-color
1256
+ // escape wouldn't visibly re-apply because we already emitted an explicit FG.
1257
+ const FG_DEFAULT = '';
1258
+ // Some hosts (Claude Code's Windows UI, cmd.exe, older mintty) don't
1259
+ // render OSC 8 hyperlinks as clickable — the label just underlines and
1260
+ // clicks do nothing. Append a "(domain)" suffix so the destination is
1261
+ // visible/copyable everywhere. Wrap the suffix in OSC 8 too so terminals
1262
+ // that DO support hyperlinks give users TWO click targets (label AND
1263
+ // domain hint) instead of one — some Windows hosts render one but not
1264
+ // the other depending on how the statusline row is parsed.
1265
+ // Only for URLs (not educational tips), and only when the label doesn't
1266
+ // already end in the domain to avoid duplication.
1267
+ let visibleUrlHint = '';
1268
+ if (promo.url) {
1269
+ try {
1270
+ const host = new URL(promo.url).hostname.replace(/^www\./, '');
1271
+ // Strip the click-redirect wrapper so users see the FINAL destination,
1272
+ // not funnel.ruv.io. If the URL is /v1/click/<id>?to=<encoded>, pull the target.
1273
+ let displayHost = host;
1274
+ try {
1275
+ const to = new URL(promo.url).searchParams.get('to');
1276
+ if (to) displayHost = new URL(to).hostname.replace(/^www\./, '');
1277
+ } catch { /* not a click-redirect, keep the raw host */ }
1278
+ if (displayHost && !label.toLowerCase().endsWith(displayHost.toLowerCase())) {
1279
+ // safeTerminalLink returns the plain string if URL isn't allowlisted
1280
+ // or the terminal can't do OSC 8 — either way the domain stays visible.
1281
+ const clickableDomain = safeTerminalLink(displayHost, promo.url);
1282
+ visibleUrlHint = DIM_ON + ' (' + clickableDomain + ')' + DIM_OFF;
1283
+ }
1284
+ } catch { /* malformed URL — omit hint, never break the row */ }
1285
+ }
1286
+ // "Entire row clickable" (user request) — wrap the whole assembled
1287
+ // string in ONE OSC 8 hyperlink instead of just the label. The command
1288
+ // portion keeps its bold + bright-white treatment (no underline) so it
1289
+ // still VISUALLY reads as a shell command the user should type, not a
1290
+ // link — but if the user clicks anywhere on the row (label, domain
1291
+ // hint, connector, even the command text), the terminal opens the URL.
1292
+ // Clicking DOES NOT execute the command; it just opens the target URL,
1293
+ // which is safe. Terminals that ignore OSC 8 render the whole row as
1294
+ // styled text and no click behavior — the previous fallback (visible
1295
+ // domain suffix) still keeps the destination readable.
1296
+ const wrapWholeRowInHyperlink = (assembled) => {
1297
+ if (!promo.url) return assembled;
1298
+ if (!terminalSupportsHyperlinks()) return assembled;
1299
+ let parsed;
1300
+ try { parsed = new URL(promo.url); } catch { return assembled; }
1301
+ if (parsed.protocol !== 'https:') return assembled;
1302
+ if (!PROMO_LINK_HOSTS.has(parsed.hostname)) return assembled;
1303
+ const ESC = '';
1304
+ return ESC + ']8;;' + parsed.href + ESC + '\\' + assembled + ESC + ']8;;' + ESC + '\\';
1305
+ };
1306
+ // Visual styling stays per-part. We only add the OSC 8 wrap around the
1307
+ // combined string, so the whole row is one click target.
1308
+ const labelStyled = promo.url ? UL_ON + label + UL_OFF : label;
1309
+ if (!command) return wrapWholeRowInHyperlink(labelStyled + visibleUrlHint);
1310
+ return wrapWholeRowInHyperlink(
1311
+ labelStyled + visibleUrlHint
1312
+ + DIM_ON + manageWord + DIM_OFF
1313
+ + BOLD_ON + FG_BRIGHT_WHITE + command + FG_DEFAULT + BOLD_OFF
1314
+ );
1315
+ } catch (e) {
1316
+ return null; // the promo row must never break the statusline
1317
+ }
1318
+ }
1319
+
1320
+ // JSON output — delegates to CLI for accuracy; caller can use --json flag
1321
+ function generateJSON() {
1322
+ const d = getStatuslineData();
1323
+ const git = getGitInfo();
1324
+ return Object.assign({}, d, {
1325
+ user: Object.assign({ name: git.name, gitBranch: git.gitBranch }, d.user || {}),
1326
+ git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
1327
+ lastUpdated: new Date().toISOString(),
1328
+ });
1329
+ }
1330
+
1331
+ // ─── Main ───────────────────────────────────────────────────────
1332
+ if (process.argv.includes('--json')) {
1333
+ console.log(JSON.stringify(generateJSON(), null, 2));
1334
+ } else if (process.argv.includes('--compact')) {
1335
+ console.log(JSON.stringify(generateJSON()));
1336
+ } else {
1337
+ console.log(generateStatusline());
1338
+ }