mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
* fix(cli): make runtime status and learning signals truthful * fix(metaharness): accept padded scan severities * fix(codex): make core hooks and status skill native * fix(security): make witness verification hermetic * fix(metrics): expose grounded learning outcomes * fix(hooks): deduplicate project and plugin events * fix(verification): preserve security audit payloads * fix(runtime): stabilize dual memory skills and MCP schemas * fix(cli): harden helper signing and reflexion health * test(release): align helper and container invariants * chore(release): prepare v3.32.36 * test(funnel): align gates with cold-start seed pool * fix(ci): document MCP environment precedence
1224 lines
57 KiB
JavaScript
1224 lines
57 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* RuFlo V3 Statusline — delegation build (#2195)
|
|
*
|
|
* Fix for ruvnet/ruflo#2195: the previous version re-implemented all data
|
|
* readers locally using fragile file probes that missed AgentDB patterns,
|
|
* the v3/docs/adr/ ADR directory, and the real vector count.
|
|
*
|
|
* This version delegates to 'npx @claude-flow/cli hooks statusline --json'
|
|
* as the single source of truth. That command queries AgentDB directly,
|
|
* counts ADRs in both directories, and reports the real intelligence pct.
|
|
*
|
|
* ADR counting falls back to local file reads so the display still works
|
|
* without network access (counts both v3/docs/adr/ and v3/implementation/adrs/).
|
|
*
|
|
* Cache: JSON result is cached in /tmp for 10s so rapid prompt triggers
|
|
* (every keystroke in some shells) don't hammer the CLI on every call.
|
|
*
|
|
* Usage: node statusline.cjs [--json] [--compact] [--dashboard]
|
|
*/
|
|
|
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync } = require('child_process');
|
|
const os = require('os');
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
maxAgents: 15,
|
|
// Header identity defaults to project/repository name. Set `author` to
|
|
// retain the previous `git config user.name` display (#2682).
|
|
identityMode: (process.env.RUFLO_STATUSLINE_IDENTITY || 'project').toLowerCase(),
|
|
// Session-cost display. Claude Code's cost.total_cost_usd is a client-side
|
|
// estimate that "may differ from your actual bill" and reads as misleading on
|
|
// subscription plans, where token usage is not billed per dollar. These let
|
|
// each user pick what the segment means to them without changing the default.
|
|
// RUFLO_STATUSLINE_COST_SYMBOL override the leading '$' (e.g. ⚡, €, 🌱);
|
|
// set to an empty string for the number alone.
|
|
// RUFLO_STATUSLINE_HIDE_COST 1/true/yes/on removes the segment entirely.
|
|
costSymbol: process.env.RUFLO_STATUSLINE_COST_SYMBOL ?? '$',
|
|
hideCost: /^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_HIDE_COST || ''),
|
|
};
|
|
|
|
const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
// Replaced by statusline-generator with the package root of the CLI that
|
|
// installed this helper. This survives custom npm prefixes and bundled Node
|
|
// runtimes whose process.execPath belongs to a different tree (#2811).
|
|
const BAKED_INSTALL_ROOT = "";
|
|
|
|
// ─── Delegation cache ───────────────────────────────────────────
|
|
// Cache the CLI JSON result so rapid prompt re-renders (Claude Code
|
|
// refreshes the statusline several times a second while streaming) don't
|
|
// re-invoke the CLI each time.
|
|
// #2337 bumped 10s → 60s.
|
|
// Followup for anthropics/claude-code#70200 (Windows console-flash bug —
|
|
// claude.exe spawns hook/statusline subprocesses without CREATE_NO_WINDOW,
|
|
// producing a visible cmd flash on every render): bumped 60s → 300s to
|
|
// reduce the flash rate 5x on Windows until the upstream fix ships.
|
|
// Tradeoff: stat/git counters update every 5min instead of every 1min;
|
|
// promo/insight row still rotates on its own tighter 20s promoFresh clock.
|
|
const CACHE_FILE = path.join(os.tmpdir(), 'ruflo-statusline-cache-' + require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 8) + '.json');
|
|
const CACHE_TTL_MS = 300000;
|
|
|
|
// The promo/insight row is designed to rotate on a 20s cadence (funnel/
|
|
// rotation.ts's ROTATION_SLOT_MS / funnel/promo.ts's insight-slot check —
|
|
// duplicated here as a bare number since this generated script has no
|
|
// runtime import of the funnel module; keep in sync if that constant ever
|
|
// changes). The rotation slot is only ever (re)computed SERVER-SIDE inside
|
|
// the CLI subprocess this file shells out to — so a general 60s data cache
|
|
// (correct and necessary for #2337) silently made that 20s design
|
|
// unreachable: cache.fresh stayed true across 2-3 whole rotation slots,
|
|
// so the row visibly "didn't rotate" (user report). Fix: track promo
|
|
// freshness on its OWN, tighter clock — when it lags behind the current
|
|
// slot, fall through to a real CLI call even though the REST of the
|
|
// cached data (security/swarm/system) is still within CACHE_TTL_MS. This
|
|
// does not touch or regress #2337's fix; it only adds a narrower check.
|
|
const PROMO_ROTATION_SLOT_MS = 20000;
|
|
|
|
// Persistent last-known-good promo record. Lives outside the /tmp cache so it
|
|
// survives a full cache wipe / cache write race / CLI failure combo. Written
|
|
// every time we successfully render a promo; read as a last resort so the row
|
|
// never blinks out mid-session (was: 'promo shows then hides' bug report).
|
|
const PROMO_MEMO_FILE = path.join(os.homedir(), '.ruflo', 'statusline-promo.json');
|
|
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.
|
|
|
|
// #2337: resolve an already-installed @claude-flow/cli (or ruflo) bin so we
|
|
// can invoke it directly via `node`. The previous version called
|
|
// `npx --yes @claude-flow/cli@latest` on every uncached render, which forces
|
|
// a registry resolution + cold-start of the entire CLI per render. With
|
|
// multiple concurrent Claude Code sessions this storms the host (reporter
|
|
// saw load average 40-65 on a 12-core box).
|
|
//
|
|
// Returns EVERY existing bin/cli.js candidate, in preference order (project,
|
|
// monorepo, plugin marketplace, global node_modules including custom-prefix
|
|
// layouts like ~/.npm-global) — mirrors getPkgVersion()'s own path probing.
|
|
//
|
|
// Returns a list, not a single winner: `fs.existsSync` only proves a file is
|
|
// present, not that it actually runs. A marketplace/npx-cached install can
|
|
// exist on disk but be broken (observed in practice: a stale marketplace
|
|
// checkout whose dist/ imports a workspace package, '@claude-flow/cli-core',
|
|
// that isn't bundled there — every invocation throws ERR_MODULE_NOT_FOUND).
|
|
// Picking the first EXISTING path and never falling through meant a single
|
|
// broken install silently killed the promo row for the entire session (the
|
|
// CLI call always failed, so the memo could never refresh and eventually
|
|
// expired). getStatuslineData() now walks this whole list and tries the next
|
|
// candidate on failure, so one broken install can't permanently wedge it.
|
|
function resolveCliBinCandidates() {
|
|
const candidates = [];
|
|
try {
|
|
const home = os.homedir();
|
|
candidates.push(
|
|
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
|
|
path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
|
path.join(CWD, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
|
path.join(CWD, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
|
);
|
|
try {
|
|
const binDir = path.dirname(process.execPath);
|
|
const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
|
|
for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
|
|
if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
|
|
}
|
|
for (const gm of globalModuleDirs) {
|
|
candidates.push(
|
|
path.join(gm, 'ruflo', 'bin', 'cli.js'),
|
|
path.join(gm, '@claude-flow', 'cli', 'bin', 'cli.js'),
|
|
);
|
|
}
|
|
} catch { /* ignore */ }
|
|
} catch { /* ignore */ }
|
|
return candidates.filter((p) => {
|
|
try {
|
|
if (!fs.existsSync(p)) return false;
|
|
// A candidate's bin/cli.js can exist on disk while its compiled
|
|
// dist/ never got built (Claude Code's own plugin marketplace just
|
|
// git-clones the repo — no install/build step — so every marketplace
|
|
// install is a source-only checkout by construction). Importing
|
|
// dist/src/index.js from bin/cli.js then throws MODULE_NOT_FOUND on
|
|
// every real command; only --version happens to survive it. Check
|
|
// for the compiled entrypoint too so a doomed candidate is skipped
|
|
// up front instead of wasting a spawn-and-fail on every render.
|
|
return fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'));
|
|
} catch { return false; }
|
|
});
|
|
}
|
|
|
|
// Return { fresh, promoFresh, data }. 'fresh' is true only if within the TTL
|
|
// — but data is returned regardless (stale-while-revalidate). This lets us
|
|
// serve last known state (specifically the promo row) when the CLI is
|
|
// slow/unavailable, so users don't see the funnel row flicker in and out on
|
|
// cache expiry. 'promoFresh' is a SEPARATE, tighter check on the same clock
|
|
// as PROMO_ROTATION_SLOT_MS — see that constant's comment for why the promo
|
|
// row needs its own freshness bound distinct from the general 60s TTL.
|
|
function readCache() {
|
|
try {
|
|
if (fs.existsSync(CACHE_FILE)) {
|
|
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
|
|
if (raw && raw._ts && raw.data) {
|
|
const age = Date.now() - raw._ts;
|
|
return { fresh: age < CACHE_TTL_MS, promoFresh: age < PROMO_ROTATION_SLOT_MS, data: raw.data };
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
return { fresh: false, promoFresh: false, data: null };
|
|
}
|
|
|
|
function writeCache(data) {
|
|
try { fs.writeFileSync(CACHE_FILE, JSON.stringify({ _ts: Date.now(), data }), 'utf-8'); } catch { /* ignore */ }
|
|
// Also memoize any promo we saw so the row can survive future CLI hiccups.
|
|
try {
|
|
if (data && data.promo && typeof data.promo === 'object') {
|
|
fs.mkdirSync(path.dirname(PROMO_MEMO_FILE), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(PROMO_MEMO_FILE, JSON.stringify({ _ts: Date.now(), promo: data.promo }), { encoding: 'utf-8', mode: 0o600 });
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Last resort: read a memoized promo (up to 6h old). Used when no cache and
|
|
// no CLI response is available — the row still renders, so users don't see
|
|
// the disclosure blink out. Returns null when the memo is absent, expired,
|
|
// or malformed. Never throws.
|
|
function readPromoMemo() {
|
|
try {
|
|
if (!fs.existsSync(PROMO_MEMO_FILE)) return null;
|
|
const raw = JSON.parse(fs.readFileSync(PROMO_MEMO_FILE, 'utf-8'));
|
|
if (raw && raw._ts && (Date.now() - raw._ts) < PROMO_MEMO_TTL_MS && raw.promo) {
|
|
return raw.promo;
|
|
}
|
|
} catch { /* ignore */ }
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Single source of truth: delegate to the CLI hooks statusline --json command.
|
|
* Falls back to a minimal static object on failure so the statusline still renders.
|
|
*
|
|
* Fix for ruflo#2195: the previous local readers returned 0 for AgentDB patterns
|
|
* (missed the .swarm/memory.db → AgentDB path), computed dddProgress wrong,
|
|
* and only counted ADRs in v3/implementation/adrs/ (missed v3/docs/adr/).
|
|
*/
|
|
// Overlay the memoized promo onto any data object that's missing one. This is
|
|
// the safety net that keeps the funnel row rendered when an OLDER cached CLI
|
|
// version is picked up by npx — that older CLI succeeds but omits promo, so
|
|
// the JSON round-trips clean but without our row. We patch it back here.
|
|
function overlayMemoPromo(data) {
|
|
if (data && !data.promo) {
|
|
const memoPromo = readPromoMemo();
|
|
if (memoPromo) data.promo = memoPromo;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function getStatuslineData() {
|
|
const cache = readCache();
|
|
// Both clocks must be satisfied to skip the CLI call entirely: the general
|
|
// 60s TTL (#2337 — don't re-spawn the CLI on every rapid re-render) AND the
|
|
// tighter promo-rotation clock (this fix — don't let a still-fresh 60s
|
|
// cache silently freeze the promo/insight row across multiple 20s slots).
|
|
if (cache.fresh && cache.promoFresh) {
|
|
return applyLocalOverlays(overlayMemoPromo(cache.data));
|
|
}
|
|
|
|
// #2337: prefer an already-installed CLI bin via direct `node` invocation —
|
|
// no npx, no registry round-trip, no @latest re-resolve per render. Try
|
|
// every candidate that actually EXISTS (not just the first) before falling
|
|
// back to `npx --prefer-offline @claude-flow/cli` (no @latest); an existing
|
|
// but broken install (e.g. a stale marketplace checkout missing a bundled
|
|
// workspace dep) must not block trying the next one.
|
|
//
|
|
// No `2>/dev/null` here (deliberately) — the execSync call below already
|
|
// sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
|
|
// Node level regardless of shell. The redirect was redundant on POSIX and
|
|
// actively broke every candidate on Windows: cmd.exe (execSync's default
|
|
// shell there) doesn't understand /dev/null, so the CLI delegation always
|
|
// failed, silently degrading every render to buildLocalFallback() — 0%
|
|
// intelligence and an empty promo row (the memo cache that keeps the row
|
|
// populated across CLI hiccups is only ever written from a SUCCESSFUL
|
|
// delegation, so it could never get seeded on Windows either).
|
|
const cmds = resolveCliBinCandidates()
|
|
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
|
|
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
|
|
for (const cmd of cmds) {
|
|
try {
|
|
const raw = execSync(
|
|
cmd,
|
|
{ encoding: 'utf-8', timeout: 8000, stdio: ['pipe', 'pipe', 'pipe'], cwd: CWD, windowsHide: true }
|
|
).trim();
|
|
// The CLI may emit preamble lines before the JSON — find the first '{'.
|
|
const jsonStart = raw.indexOf('{');
|
|
if (jsonStart === -1) throw new Error('no JSON in CLI output');
|
|
const data = JSON.parse(raw.slice(jsonStart));
|
|
// Overlay every block the CLI JSON omits (adrs/agentdb/tests/hooks/integration)
|
|
// with real local reads, so those segments reflect actual state instead of 0.
|
|
applyLocalOverlays(data);
|
|
overlayMemoPromo(data);
|
|
writeCache(data);
|
|
return data;
|
|
} catch { /* this candidate unavailable, broken, or timed out — try the next */ }
|
|
}
|
|
|
|
// Stale-while-revalidate: if we have any cached data, keep serving it so the
|
|
// funnel row doesn't flicker on CLI hiccups. Overlay fresh local reads for
|
|
// the segments the CLI JSON doesn't populate; the promo row survives.
|
|
if (cache.data) {
|
|
applyLocalOverlays(cache.data);
|
|
overlayMemoPromo(cache.data);
|
|
return cache.data;
|
|
}
|
|
|
|
// Last resort: local probes + memo. Users still see the funnel row.
|
|
return overlayMemoPromo(buildLocalFallback());
|
|
}
|
|
|
|
// Count ADRs from BOTH known directories (fix for ruflo#2195: old code missed
|
|
// v3/docs/adr/ which holds ADR-088..ADR-137, i.e. 41 of the 128 total ADRs).
|
|
function getLocalADRCount() {
|
|
const adrDirs = [
|
|
path.join(CWD, 'v3', 'implementation', 'adrs'),
|
|
path.join(CWD, 'v3', 'docs', 'adr'),
|
|
path.join(CWD, 'docs', 'adrs'),
|
|
path.join(CWD, '.claude-flow', 'adrs'),
|
|
];
|
|
let total = 0;
|
|
for (const dir of adrDirs) {
|
|
try {
|
|
if (fs.existsSync(dir)) {
|
|
const files = fs.readdirSync(dir).filter(function(f) {
|
|
return f.endsWith('.md') && (f.startsWith('ADR-') || f.startsWith('adr-') || /^\d{4}-/.test(f));
|
|
});
|
|
total += files.length;
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
return { count: total, implemented: total, compliance: 0 };
|
|
}
|
|
|
|
// ─── Local overlays for segments the CLI JSON omits ──────────────
|
|
// 'hooks statusline --json' only returns user/v3Progress/security/swarm/system.
|
|
// agentdb/tests/hooks/integration are never populated, so without these overlays
|
|
// they render as a permanent 0. Each reader is cheap and degrades to zeros.
|
|
|
|
// Real AgentDB stats from the local memory DB. Vectors live in .swarm/memory.db
|
|
// (sql.js + HNSW); ruvector.db is an opaque redb store counted only toward size.
|
|
// One read-only sqlite3 query (mode=ro never takes a write lock the daemon owns).
|
|
function getLocalAgentDB() {
|
|
const result = { vectorCount: 0, dbSizeKB: 0, hasHnsw: false };
|
|
try {
|
|
let bytes = 0;
|
|
for (const f of ['.swarm/memory.db', 'ruvector.db']) {
|
|
try { bytes += fs.statSync(path.join(CWD, f)).size; } catch { /* missing */ }
|
|
}
|
|
result.dbSizeKB = Math.round(bytes / 1024);
|
|
|
|
const memDb = path.join(CWD, '.swarm', 'memory.db');
|
|
if (fs.existsSync(memDb)) {
|
|
const Q = String.fromCharCode(34);
|
|
// Two INDEPENDENT statements -- do NOT combine into one. Coupling the
|
|
// vector count with the vector_indexes row count in a single statement
|
|
// meant that on a DB missing the vector_indexes table (older/agentdb-
|
|
// written DBs), the whole statement failed at PREPARE time (SQLite
|
|
// compiles the full SQL before running), so the valid memory_entries
|
|
// count was discarded too and the statusline showed Vectors 0 despite
|
|
// thousands of real vectors. Split so a missing table can only zero the
|
|
// HNSW flag, never the count. The init self-heal provisions the table so
|
|
// the flag recovers on the next ruflo init / MCP start.
|
|
const countSql = Q + 'SELECT COUNT(*) FROM memory_entries WHERE embedding IS NOT NULL;' + Q;
|
|
const vc = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + countSql, 1500);
|
|
if (vc) result.vectorCount = parseInt(vc, 10) || 0;
|
|
// HNSW flag: separate statement. If vector_indexes is absent, sqlite3
|
|
// exits non-zero and safeExec returns empty -- hasHnsw stays false (exact
|
|
// original semantics: at least one index-config row present).
|
|
const hnswSql = Q + 'SELECT COUNT(*) FROM vector_indexes;' + Q;
|
|
const hn = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + hnswSql, 1500);
|
|
if (hn) result.hasHnsw = (parseInt(hn, 10) || 0) > 0;
|
|
}
|
|
} catch { /* ignore */ }
|
|
return result;
|
|
}
|
|
|
|
// Count test files via a bounded directory walk (no file reads).
|
|
function getLocalTests() {
|
|
let testFiles = 0;
|
|
function countTests(dir, depth) {
|
|
if ((depth || 0) > 4) return;
|
|
try {
|
|
if (!fs.existsSync(dir)) return;
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
|
|
countTests(path.join(dir, e.name), (depth || 0) + 1);
|
|
} else if (e.isFile() && (e.name.includes('.test.') || e.name.includes('.spec.') || e.name.startsWith('test_') || e.name.startsWith('spec_'))) {
|
|
testFiles++;
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
for (const d of ['tests', 'test', '__tests__', 'src', 'v3']) countTests(path.join(CWD, d));
|
|
return { testFiles, testCases: testFiles * 4 };
|
|
}
|
|
|
|
// Count configured hooks from project .claude/settings.json. Claude Code hooks
|
|
// have no enabled/disabled flag, so every configured hook counts as enabled.
|
|
function getLocalHooks() {
|
|
const result = { enabled: 0, total: 0 };
|
|
try {
|
|
const settings = readJSON(path.join(CWD, '.claude', 'settings.json'));
|
|
const hooks = settings && settings.hooks;
|
|
if (hooks && typeof hooks === 'object') {
|
|
let n = 0;
|
|
for (const ev of Object.keys(hooks)) {
|
|
const groups = hooks[ev];
|
|
if (Array.isArray(groups)) {
|
|
for (const g of groups) {
|
|
if (g && Array.isArray(g.hooks)) n += g.hooks.length;
|
|
}
|
|
}
|
|
}
|
|
result.total = n;
|
|
result.enabled = n;
|
|
}
|
|
} catch { /* ignore */ }
|
|
return result;
|
|
}
|
|
|
|
// Best-effort integration block: DB presence + locally-configured stdio MCP
|
|
// servers (project .mcp.json + global ~/.claude.json). Remote connectors are
|
|
// account-managed and not present in local config, so they are not counted.
|
|
function getLocalIntegration() {
|
|
const integration = { mcpServers: { enabled: 0, total: 0 }, hasDatabase: false };
|
|
try {
|
|
for (const f of ['.swarm/memory.db', 'ruvector.db']) {
|
|
if (fs.existsSync(path.join(CWD, f))) { integration.hasDatabase = true; break; }
|
|
}
|
|
const names = new Set();
|
|
const projMcp = readJSON(path.join(CWD, '.mcp.json'));
|
|
if (projMcp && projMcp.mcpServers) for (const k of Object.keys(projMcp.mcpServers)) names.add(k);
|
|
const claudeJson = readJSON(path.join(os.homedir(), '.claude.json'));
|
|
if (claudeJson) {
|
|
if (claudeJson.mcpServers) for (const k of Object.keys(claudeJson.mcpServers)) names.add(k);
|
|
const proj = claudeJson.projects && claudeJson.projects[CWD];
|
|
if (proj && proj.mcpServers && !Array.isArray(proj.mcpServers)) {
|
|
for (const k of Object.keys(proj.mcpServers)) names.add(k);
|
|
}
|
|
}
|
|
integration.mcpServers.total = names.size;
|
|
integration.mcpServers.enabled = names.size;
|
|
} catch { /* ignore */ }
|
|
return integration;
|
|
}
|
|
|
|
// ─── Security freshness overlay (ruvnet/ruflo#2776) ──────────────
|
|
// The shipped CLI producer (dist/src/funnel/local-signals.js getSecurityStatus)
|
|
// only ever emits PENDING / CLEAN / ISSUES — it captures `scannedAt` but never
|
|
// inspects it, so a year-old scan renders 🛡 ✓ forever and the renderer's
|
|
// STALE / IN_PROGRESS branches are unreachable. Worse, when CLI delegation
|
|
// fails, the stale-while-revalidate cache (readCache() below) keeps serving
|
|
// the pre-scan PENDING pill indefinitely, so a user who runs the advertised
|
|
// `ruflo security scan` sees no change — the pill freezes at "scan pending".
|
|
//
|
|
// This overlay recomputes the security block from disk on EVERY render (same
|
|
// pattern as adrs/agentdb/tests/hooks above), which:
|
|
// 1) Makes STALE reachable — when the newest scan is older than
|
|
// RUFLO_SCAN_STALE_HOURS (default 24h — matches the CVE feed refresh
|
|
// cadence), report STALE regardless of what the cached CLI JSON says.
|
|
// 2) Makes IN_PROGRESS reachable — when a `scan-in-progress` marker file
|
|
// exists and is younger than SECURITY_IN_PROGRESS_MAX_MIN (guards against
|
|
// a crashed scan leaving the marker behind).
|
|
// 3) Caps the "scan pending" display window — if PENDING has been shown for
|
|
// >RUFLO_SCAN_PENDING_CAP_MIN (default 30) without a completion write,
|
|
// switch to STALE and stop rendering the yellow indicator. The tracker
|
|
// lives in ~/.ruflo/statusline-scan-pending-since.json, keyed by CWD
|
|
// hash so multiple project checkouts don't collide.
|
|
// 4) Since this runs AFTER readCache() serves stale data, it bypasses the
|
|
// "pill freezes at PENDING" freeze in defect 2 — the overlay reads
|
|
// fresh disk state even when the CLI delegation is broken.
|
|
const SECURITY_STALE_HOURS = Math.max(1, parseInt(process.env.RUFLO_SCAN_STALE_HOURS || '24', 10) || 24);
|
|
const SECURITY_PENDING_CAP_MIN = Math.max(1, parseInt(process.env.RUFLO_SCAN_PENDING_CAP_MIN || '30', 10) || 30);
|
|
const SECURITY_IN_PROGRESS_MAX_MIN = 30; // marker older than this = crashed scan; treat as absent
|
|
const PENDING_TRACK_FILE = path.join(os.homedir(), '.ruflo', 'statusline-scan-pending-since.json');
|
|
const CWD_KEY = require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 12);
|
|
|
|
function readPendingSince() {
|
|
try {
|
|
if (!fs.existsSync(PENDING_TRACK_FILE)) return null;
|
|
const raw = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8'));
|
|
if (raw && typeof raw === 'object' && typeof raw[CWD_KEY] === 'number') return raw[CWD_KEY];
|
|
} catch { /* ignore */ }
|
|
return null;
|
|
}
|
|
|
|
function writePendingSince(ts) {
|
|
try {
|
|
let obj = {};
|
|
if (fs.existsSync(PENDING_TRACK_FILE)) {
|
|
try { obj = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8')) || {}; } catch { obj = {}; }
|
|
}
|
|
if (ts === null) { delete obj[CWD_KEY]; } else { obj[CWD_KEY] = ts; }
|
|
fs.mkdirSync(path.dirname(PENDING_TRACK_FILE), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(PENDING_TRACK_FILE, JSON.stringify(obj), { encoding: 'utf-8', mode: 0o600 });
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function getLocalSecurity(cliSecurity) {
|
|
const base = (cliSecurity && typeof cliSecurity === 'object')
|
|
? Object.assign({}, cliSecurity)
|
|
: { status: 'NONE', findings: 0, cvesFixed: 0, totalCves: 0 };
|
|
base.findings = Math.max(0, base.findings || 0);
|
|
|
|
const scanDir = path.join(CWD, '.claude', 'security-scans');
|
|
|
|
// Detect a live in-progress marker (writer opts-in by writing this file).
|
|
let inProgress = false;
|
|
try {
|
|
const marker = path.join(scanDir, 'scan-in-progress');
|
|
if (fs.existsSync(marker)) {
|
|
const ageMin = (Date.now() - fs.statSync(marker).mtimeMs) / 60000;
|
|
if (ageMin < SECURITY_IN_PROGRESS_MAX_MIN) inProgress = true;
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
// Find newest scan-*.json by mtime and read its findings/timestamp.
|
|
let newestPath = null;
|
|
let newestMtime = 0;
|
|
try {
|
|
if (fs.existsSync(scanDir)) {
|
|
for (const name of fs.readdirSync(scanDir)) {
|
|
if (!name.startsWith('scan-') || !name.endsWith('.json')) continue;
|
|
try {
|
|
const st = fs.statSync(path.join(scanDir, name));
|
|
if (st.mtimeMs > newestMtime) { newestMtime = st.mtimeMs; newestPath = path.join(scanDir, name); }
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
if (newestPath) {
|
|
// We have a scan on disk — the never-scanned pending tracker is no longer
|
|
// relevant. Clear it so a re-created directory can start a fresh window.
|
|
writePendingSince(null);
|
|
|
|
let scannedAtMs = newestMtime;
|
|
let findings = base.findings;
|
|
try {
|
|
const j = JSON.parse(fs.readFileSync(newestPath, 'utf-8'));
|
|
// scannedAt (CLI producer's field name) OR timestamp (writer's field name).
|
|
const isoStr = (j && (j.scannedAt || j.timestamp)) || null;
|
|
if (isoStr) {
|
|
const t = Date.parse(isoStr);
|
|
if (!isNaN(t)) scannedAtMs = t;
|
|
}
|
|
// findings may be a number, an array, or nested in summary.total.
|
|
if (j) {
|
|
if (typeof j.findings === 'number') findings = j.findings;
|
|
else if (Array.isArray(j.findings)) findings = j.findings.length;
|
|
else if (j.summary && typeof j.summary.total === 'number') findings = j.summary.total;
|
|
}
|
|
} catch { /* ignore parse — fall back to mtime + cached findings */ }
|
|
|
|
base.findings = Math.max(0, findings || 0);
|
|
base.scannedAt = new Date(scannedAtMs).toISOString();
|
|
|
|
const ageHours = (Date.now() - scannedAtMs) / 3600000;
|
|
if (ageHours >= SECURITY_STALE_HOURS) {
|
|
// Stale but findings still render red (a year-old ISSUES scan is still bad).
|
|
base.status = 'STALE';
|
|
} else if (inProgress) {
|
|
base.status = 'IN_PROGRESS';
|
|
} else if (base.findings > 0) {
|
|
base.status = 'ISSUES';
|
|
} else {
|
|
base.status = 'CLEAN';
|
|
}
|
|
return base;
|
|
}
|
|
|
|
// No scan file. If a live marker exists, we're mid-scan.
|
|
if (inProgress) {
|
|
base.status = 'IN_PROGRESS';
|
|
// Reset the pending tracker so, if the scan crashes mid-flight, the next
|
|
// render starts a fresh N-minute pending window instead of an already-expired one.
|
|
writePendingSince(null);
|
|
return base;
|
|
}
|
|
|
|
// Truly never-scanned: track how long we've shown PENDING. After the cap,
|
|
// escalate to STALE with the dim/gray glyph so the pill visibly stops
|
|
// shouting for attention — the user has either ignored it for 30 min or
|
|
// the scan is silently failing to write.
|
|
let pendingSince = readPendingSince();
|
|
if (pendingSince === null || typeof pendingSince !== 'number') {
|
|
pendingSince = Date.now();
|
|
writePendingSince(pendingSince);
|
|
}
|
|
const pendingAgeMin = (Date.now() - pendingSince) / 60000;
|
|
base.status = (pendingAgeMin >= SECURITY_PENDING_CAP_MIN) ? 'STALE' : 'PENDING';
|
|
return base;
|
|
}
|
|
|
|
// Overlay every locally-derived block onto the CLI data (mutates in place).
|
|
function applyLocalOverlays(data) {
|
|
data.adrs = getLocalADRCount();
|
|
data.agentdb = getLocalAgentDB();
|
|
data.tests = getLocalTests();
|
|
data.hooks = getLocalHooks();
|
|
data.integration = getLocalIntegration();
|
|
// Security overlay: recompute freshness from disk on every render so cached
|
|
// CLI JSON can never freeze the pill at PENDING. See getLocalSecurity() above.
|
|
data.security = getLocalSecurity(data.security);
|
|
return data;
|
|
}
|
|
|
|
// Minimal local fallback when the CLI is not installed or times out.
|
|
// Returns a structure that matches the CLI JSON schema so the renderer works.
|
|
function buildLocalFallback() {
|
|
const memMB = Math.floor(process.memoryUsage().heapUsed / 1024 / 1024);
|
|
|
|
return applyLocalOverlays({
|
|
user: { name: 'user', gitBranch: '', modelName: 'Claude Code' },
|
|
v3Progress: { domainsCompleted: 0, totalDomains: 5, dddProgress: 0, patternsLearned: 0, sessionsCompleted: 0 },
|
|
security: { status: 'NONE', findings: 0, cvesFixed: 0, totalCves: 0 },
|
|
swarm: { activeAgents: 0, maxAgents: CONFIG.maxAgents, coordinationActive: false },
|
|
system: { memoryMB: memMB, contextPct: 0, intelligencePct: 0, subAgents: 0 },
|
|
lastUpdated: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
// ANSI colors
|
|
const c = {
|
|
reset: '\x1b[0m',
|
|
bold: '\x1b[1m',
|
|
dim: '\x1b[2m',
|
|
red: '\x1b[0;31m',
|
|
green: '\x1b[0;32m',
|
|
yellow: '\x1b[0;33m',
|
|
blue: '\x1b[0;34m',
|
|
purple: '\x1b[0;35m',
|
|
cyan: '\x1b[0;36m',
|
|
brightRed: '\x1b[1;31m',
|
|
brightGreen: '\x1b[1;32m',
|
|
brightYellow: '\x1b[1;33m',
|
|
brightBlue: '\x1b[1;34m',
|
|
brightPurple: '\x1b[1;35m',
|
|
brightCyan: '\x1b[1;36m',
|
|
brightWhite: '\x1b[1;37m',
|
|
};
|
|
|
|
// Safe execSync with strict timeout (returns empty string on failure)
|
|
function safeExec(cmd, timeoutMs) {
|
|
try {
|
|
return execSync(cmd, {
|
|
encoding: 'utf-8',
|
|
timeout: timeoutMs || 2000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
// Windows: without this, every execSync spawns cmd.exe /d /s /c which
|
|
// flashes a visible console window every render (~1/min via the 60s
|
|
// cache TTL). windowsHide runs the child in a hidden window instead.
|
|
// No-op on POSIX. Fix for #2XXX (user report: "cmd prompt keeps opening").
|
|
windowsHide: true,
|
|
}).trim();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Safe JSON file reader (returns null on failure)
|
|
function readJSON(filePath) {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
}
|
|
} catch { /* ignore */ }
|
|
return null;
|
|
}
|
|
|
|
// ─── Git info (pure-Node / single exec — needed for branch display) ──────────
|
|
|
|
function getGitInfo() {
|
|
const result = {
|
|
name: path.basename(CWD) || 'project', gitBranch: '', modified: 0, untracked: 0,
|
|
staged: 0, ahead: 0, behind: 0,
|
|
};
|
|
|
|
const script = [
|
|
'git rev-parse --show-toplevel 2>/dev/null || pwd',
|
|
'echo "---SEP---"',
|
|
'git config user.name 2>/dev/null || echo user',
|
|
'echo "---SEP---"',
|
|
'git branch --show-current 2>/dev/null',
|
|
'echo "---SEP---"',
|
|
'git status --porcelain 2>/dev/null',
|
|
'echo "---SEP---"',
|
|
'git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"',
|
|
].join('; ');
|
|
|
|
const raw = safeExec("sh -c '" + script + "'", 3000);
|
|
if (!raw) return result;
|
|
|
|
const parts = raw.split('---SEP---').map(function(s) { return s.trim(); });
|
|
if (parts.length >= 5) {
|
|
const projectName = path.basename(parts[0] || CWD) || path.basename(CWD) || 'project';
|
|
const authorName = parts[1] || 'user';
|
|
result.name = CONFIG.identityMode === 'author' ? authorName : projectName;
|
|
result.gitBranch = parts[2] || '';
|
|
|
|
if (parts[3]) {
|
|
for (const line of parts[3].split('\n')) {
|
|
if (!line || line.length < 2) continue;
|
|
const x = line[0], y = line[1];
|
|
if (x === '?' && y === '?') { result.untracked++; continue; }
|
|
if (x !== ' ' && x !== '?') result.staged++;
|
|
if (y !== ' ' && y !== '?') result.modified++;
|
|
}
|
|
}
|
|
|
|
const ab = (parts[4] || '0 0').split(/\s+/);
|
|
result.ahead = parseInt(ab[0]) || 0;
|
|
result.behind = parseInt(ab[1]) || 0;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Detect model name from Claude config (pure file reads, no exec)
|
|
function getModelName() {
|
|
try {
|
|
const claudeConfig = readJSON(path.join(os.homedir(), '.claude.json'));
|
|
if (claudeConfig && claudeConfig.projects) {
|
|
for (const [projectPath, projectConfig] of Object.entries(claudeConfig.projects)) {
|
|
if (CWD === projectPath || CWD.startsWith(projectPath + '/')) {
|
|
const usage = projectConfig.lastModelUsage;
|
|
if (usage) {
|
|
const ids = Object.keys(usage);
|
|
if (ids.length > 0) {
|
|
let modelId = ids[ids.length - 1];
|
|
let latest = 0;
|
|
for (const id of ids) {
|
|
const ts = usage[id] && usage[id].lastUsedAt ? new Date(usage[id].lastUsedAt).getTime() : 0;
|
|
if (ts > latest) { latest = ts; modelId = id; }
|
|
}
|
|
if (modelId.includes('opus')) return 'Opus 4.8';
|
|
if (modelId.includes('sonnet')) return 'Sonnet 4.6';
|
|
if (modelId.includes('haiku')) return 'Haiku 4.5';
|
|
return modelId.split('-').slice(1, 3).join(' ');
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
// Fallback: settings.json model field
|
|
const settings = getSettings();
|
|
if (settings && settings.model) {
|
|
const m = settings.model;
|
|
if (m.includes('opus')) return 'Opus 4.8';
|
|
if (m.includes('sonnet')) return 'Sonnet 4.6';
|
|
if (m.includes('haiku')) return 'Haiku 4.5';
|
|
}
|
|
return 'Claude Code';
|
|
}
|
|
|
|
// ─── Stdin reader (Claude Code pipes session JSON) ──────────────
|
|
// Claude Code sends session JSON via stdin. Read synchronously so the
|
|
// script works both when invoked by Claude Code (stdin has JSON) and
|
|
// when run manually from terminal (stdin is empty/tty).
|
|
let _stdinData = null;
|
|
function getStdinData() {
|
|
if (_stdinData !== undefined && _stdinData !== null) return _stdinData;
|
|
try {
|
|
if (process.stdin.isTTY) { _stdinData = null; return null; }
|
|
const chunks = [];
|
|
const buf = Buffer.alloc(4096);
|
|
let bytesRead;
|
|
try {
|
|
while ((bytesRead = fs.readSync(0, buf, 0, buf.length, null)) > 0) {
|
|
chunks.push(buf.slice(0, bytesRead));
|
|
}
|
|
} catch { /* EOF or read error */ }
|
|
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
_stdinData = (raw && raw.startsWith('{')) ? JSON.parse(raw) : null;
|
|
} catch {
|
|
_stdinData = null;
|
|
}
|
|
return _stdinData;
|
|
}
|
|
|
|
function getModelFromStdin() {
|
|
const data = getStdinData();
|
|
return (data && data.model && data.model.display_name) ? data.model.display_name : null;
|
|
}
|
|
|
|
function getContextFromStdin() {
|
|
const data = getStdinData();
|
|
if (data && data.context_window) {
|
|
return { usedPct: Math.floor(data.context_window.used_percentage || 0) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getCostFromStdin() {
|
|
const data = getStdinData();
|
|
if (data && data.cost) {
|
|
const durationMs = data.cost.total_duration_ms || 0;
|
|
const mins = Math.floor(durationMs / 60000);
|
|
const secs = Math.floor((durationMs % 60000) / 1000);
|
|
return {
|
|
costUsd: data.cost.total_cost_usd || 0,
|
|
duration: mins > 0 ? mins + 'm' + secs + 's' : secs + 's',
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Compares dotted-numeric version strings (e.g. "3.27.1" vs "3.27.10").
|
|
// Returns >0 if a>b, <0 if a<b, 0 if equal-as-far-as-parseable. Deliberately
|
|
// simple (no prerelease/build-metadata handling) — this only orders local
|
|
// package.json versions against each other, never anything untrusted from
|
|
// a payload, so a full semver implementation would be dead weight here.
|
|
function compareVersions(a, b) {
|
|
const pa = String(a).split('.').map((n) => parseInt(n, 10));
|
|
const pb = String(b).split('.').map((n) => parseInt(n, 10));
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
const na = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
const nb = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
if (na !== nb) return na - nb;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// #2742: when CWD is a linked git worktree, it has no node_modules of its
|
|
// own (worktrees don't get their own `npm install`), so every CWD-relative
|
|
// probe in getPkgVersion() misses and the version silently falls back to
|
|
// the baked-in default — even though the main repo's install a few
|
|
// directories away is perfectly resolvable. A linked worktree's `.git` is
|
|
// a plain FILE (not a directory) containing `gitdir: <main>/.git/worktrees/
|
|
// <name>`; walk up from CWD to find it, parse the pointer, and strip the
|
|
// trailing `.git/worktrees/<name>` segment to recover the main repo root.
|
|
// Pure fs — no `git rev-parse` spawn (statusline renders are latency-
|
|
// sensitive; this doc comment's neighbors are explicit about avoiding
|
|
// spawns in the render path).
|
|
function resolveWorktreeMainRoot() {
|
|
try {
|
|
let dir = CWD;
|
|
for (;;) {
|
|
const dotGit = path.join(dir, '.git');
|
|
if (fs.existsSync(dotGit)) {
|
|
if (fs.statSync(dotGit).isFile()) {
|
|
const contents = fs.readFileSync(dotGit, 'utf-8');
|
|
const m = contents.match(/^gitdir:\s*(.+)$/m);
|
|
const wtGitDir = m && m[1].trim();
|
|
if (wtGitDir) {
|
|
// Git writes this pointer with forward slashes even on Windows
|
|
// (a git-for-windows convention for its own internal files) —
|
|
// path.sep (backslash on win32) never matches, so normalize
|
|
// before searching rather than building an OS-specific marker.
|
|
const normalized = wtGitDir.replace(/\\/g, '/');
|
|
const marker = '/.git/worktrees/';
|
|
const idx = normalized.lastIndexOf(marker);
|
|
if (idx > 0) return normalized.slice(0, idx);
|
|
}
|
|
}
|
|
return null; // a real (non-worktree) .git dir — nothing to resolve
|
|
}
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) return null; // reached filesystem root
|
|
dir = parent;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getPkgVersion() {
|
|
// Baked in at generation time from the real running CLI's own resolved
|
|
// version (see generateStatuslineScript()'s doc comment) — correct even
|
|
// when this renders via a pure npx invocation with no local install for
|
|
// the candidate scan below to find.
|
|
let ver = "3.32.8";
|
|
try {
|
|
const home = os.homedir();
|
|
const pkgPaths = [
|
|
...(BAKED_INSTALL_ROOT ? [path.join(BAKED_INSTALL_ROOT, 'package.json')] : []),
|
|
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'package.json'),
|
|
path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'package.json'),
|
|
path.join(CWD, 'node_modules', 'ruflo', 'package.json'),
|
|
path.join(CWD, 'v3', '@claude-flow', 'cli', 'package.json'),
|
|
];
|
|
// #2742: CWD is a linked git worktree with no node_modules of its own —
|
|
// probe the main repo's install too, so a worktree session shows the
|
|
// same version a main-repo session would.
|
|
const worktreeMainRoot = resolveWorktreeMainRoot();
|
|
if (worktreeMainRoot) {
|
|
pkgPaths.push(
|
|
path.join(worktreeMainRoot, 'node_modules', '@claude-flow', 'cli', 'package.json'),
|
|
path.join(worktreeMainRoot, 'node_modules', 'ruflo', 'package.json'),
|
|
path.join(worktreeMainRoot, 'v3', '@claude-flow', 'cli', 'package.json'),
|
|
);
|
|
}
|
|
// #2221: global installs (npm i -g ruflo) live outside CWD/node_modules, so the
|
|
// probes above all miss and the version falls back to the hard-coded default.
|
|
// Derive the global node_modules dir from the running node binary (no npm spawn —
|
|
// statusline renders often). Covers nvm/mise (bin/../lib/node_modules) and Windows
|
|
// (bin/node_modules) layouts.
|
|
try {
|
|
const binDir = path.dirname(process.execPath);
|
|
const globalModuleDirs = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
|
|
// #2221 follow-up: a custom npm prefix (e.g. ~/.npm-global) is decoupled from
|
|
// the node binary location, so the binDir-derived probes above all miss. Also
|
|
// probe the npm prefix from the environment and the common ~/.npm-global default.
|
|
for (const prefix of [
|
|
process.env.npm_config_prefix,
|
|
process.env.PREFIX,
|
|
path.join(home, '.local'),
|
|
path.join(home, '.npm-global'),
|
|
]) {
|
|
if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
|
|
}
|
|
for (const gm of globalModuleDirs) {
|
|
pkgPaths.push(
|
|
path.join(gm, 'ruflo', 'package.json'),
|
|
path.join(gm, '@claude-flow', 'cli', 'package.json'),
|
|
);
|
|
}
|
|
} catch { /* ignore */ }
|
|
// Pick the HIGHEST version among every candidate that exists, not the
|
|
// first one found. The marketplace plugin path is probed first (list
|
|
// order above), but Claude Code's own plugin marketplace mechanism
|
|
// syncs on its own git-pull cadence, independent of npm publishes — a
|
|
// freshly-published npm version can sit alongside a stale marketplace
|
|
// checkout for a while (observed live: marketplace one release behind
|
|
// right after a publish). Taking the first EXISTING candidate meant the
|
|
// header could show a stale version even when a newer install (e.g.
|
|
// node_modules/@claude-flow/cli from a plain npm install) was sitting right there.
|
|
for (const p of pkgPaths) {
|
|
if (!fs.existsSync(p)) continue;
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
if (pkg && typeof pkg.version === 'string' && pkg.version.length > 0) {
|
|
if (compareVersions(pkg.version, ver) > 0) ver = pkg.version;
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
} catch { /* ignore */ }
|
|
return ver;
|
|
}
|
|
|
|
// ─── Rendering ──────────────────────────────────────────────────
|
|
|
|
function progressBar(current, total) {
|
|
const width = 5;
|
|
const filled = Math.round((current / total) * width);
|
|
return '[' + '●'.repeat(filled) + '○'.repeat(width - filled) + ']';
|
|
}
|
|
|
|
function generateStatusline() {
|
|
const d = getStatuslineData();
|
|
const git = getGitInfo();
|
|
const modelName = getModelFromStdin() || (d.user && d.user.modelName) || 'Claude Code';
|
|
const ctxInfo = getContextFromStdin();
|
|
const costInfo = getCostFromStdin();
|
|
// Named RUFLO_VERSION (not pkgVersion) so the #1951 regression guard
|
|
// (scripts/audit-fix-invariants.mjs) can pin its presence in the emitted
|
|
// .cjs artifact — without it the header silently reverts to a hard-coded
|
|
// "RuFlo V3.5" for anyone whose install doesn't match the first probe path.
|
|
const RUFLO_VERSION = getPkgVersion();
|
|
|
|
const progress = d.v3Progress || {};
|
|
const security = d.security || {};
|
|
const swarm = d.swarm || {};
|
|
const system = d.system || {};
|
|
const adrs = d.adrs || {};
|
|
const hooks = d.hooks || {};
|
|
const agentdb = d.agentdb || {};
|
|
const tests = d.tests || {};
|
|
|
|
const domainsCompleted = progress.domainsCompleted || 0;
|
|
const totalDomains = progress.totalDomains || 5;
|
|
const dddProgress = progress.dddProgress || 0;
|
|
const patternsLearned = progress.patternsLearned || 0;
|
|
const activeAgents = swarm.activeAgents || 0;
|
|
const maxAgents = swarm.maxAgents || CONFIG.maxAgents;
|
|
const coordinationActive = swarm.coordinationActive || false;
|
|
const intelligencePct = system.intelligencePct || 0;
|
|
const memoryMB = system.memoryMB || 0;
|
|
const subAgents = system.subAgents || 0;
|
|
const findings = Math.max(0, security.findings || 0);
|
|
const secStatus = security.status || 'NONE';
|
|
const adrCount = adrs.count || 0;
|
|
const adrImpl = adrs.implemented || 0;
|
|
const hooksEnabled = hooks.enabled || 0;
|
|
const hooksTotal = hooks.total || 0;
|
|
const vectorCount = agentdb.vectorCount || 0;
|
|
const hasHnsw = agentdb.hasHnsw || false;
|
|
const dbSizeKB = agentdb.dbSizeKB || 0;
|
|
const testFiles = tests.testFiles || 0;
|
|
const testCases = tests.testCases || testFiles * 4;
|
|
|
|
const lines = [];
|
|
|
|
// 3-line design (fits Claude Code's visible statusline area — line 4+ gets
|
|
// replaced by the system guidance / input prompt line):
|
|
// Line 1 — Header (RuFlo version · git · model · timing · context · cost)
|
|
// Line 2 — Compressed ops (Swarm · Hooks · 🧠 · 💾 · Health)
|
|
// Line 3 — Promo / disclosure row (funnel surface, ADR-301)
|
|
|
|
// ─── Line 1: header ────────────────────────────────────────────
|
|
let header = c.bold + c.brightPurple + '▊ RuFlo V' + RUFLO_VERSION + ' ' + c.reset;
|
|
header += (coordinationActive ? c.brightCyan : c.dim) + '● ' + c.brightCyan + git.name + c.reset;
|
|
if (git.gitBranch) {
|
|
header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightBlue + '⏇ ' + git.gitBranch + c.reset;
|
|
const changes = git.modified + git.staged + git.untracked;
|
|
if (changes > 0) {
|
|
let ind = '';
|
|
if (git.staged > 0) ind += c.brightGreen + '+' + git.staged + c.reset;
|
|
if (git.modified > 0) ind += c.brightYellow + '~' + git.modified + c.reset;
|
|
if (git.untracked > 0) ind += c.dim + '?' + git.untracked + c.reset;
|
|
header += ' ' + ind;
|
|
}
|
|
if (git.ahead > 0) header += ' ' + c.brightGreen + '↑' + git.ahead + c.reset;
|
|
if (git.behind > 0) header += ' ' + c.brightRed + '↓' + git.behind + c.reset;
|
|
}
|
|
header += ' ' + c.dim + '│' + c.reset + ' ' + c.purple + modelName + c.reset;
|
|
const duration = costInfo ? costInfo.duration : '';
|
|
if (duration) header += ' ' + c.dim + '│' + c.reset + ' ' + c.cyan + '⏱ ' + duration + c.reset;
|
|
if (ctxInfo && ctxInfo.usedPct > 0) {
|
|
const ctxColor = ctxInfo.usedPct >= 90 ? c.brightRed : ctxInfo.usedPct >= 70 ? c.brightYellow : c.brightGreen;
|
|
header += ' ' + c.dim + '│' + c.reset + ' ' + ctxColor + '● ' + ctxInfo.usedPct + '% ctx' + c.reset;
|
|
}
|
|
if (!CONFIG.hideCost && costInfo && costInfo.costUsd > 0) {
|
|
header += ' ' + c.dim + '│' + c.reset + ' ' + c.brightYellow + CONFIG.costSymbol + costInfo.costUsd.toFixed(2) + c.reset;
|
|
}
|
|
lines.push(header);
|
|
|
|
// ─── Line 2: compressed ops ────────────────────────────────────
|
|
// Everything actionable in one dense row. Show only what changes what you
|
|
// do next; diagnostic detail moves to `ruflo status --verbose`.
|
|
const agentsColor = activeAgents > 0 ? c.brightGreen : c.dim;
|
|
const hooksColor = hooksEnabled > 0 ? c.brightGreen : c.dim;
|
|
const intellColor = intelligencePct >= 80 ? c.brightGreen : intelligencePct >= 40 ? c.brightYellow : c.dim;
|
|
const swarmInd = coordinationActive ? c.brightGreen + '◉' + c.reset + ' ' : c.dim + '○' + c.reset + ' ';
|
|
const healthAllGreen = (secStatus === 'CLEAN' || secStatus === 'NONE') && findings === 0;
|
|
const opsParts = [];
|
|
opsParts.push(c.cyan + 'Swarm ' + swarmInd + agentsColor + activeAgents + c.reset + '/' + c.brightWhite + maxAgents + c.reset);
|
|
if (subAgents > 0) opsParts.push(c.brightPurple + '👥 ' + subAgents + c.reset);
|
|
opsParts.push(c.cyan + 'Hooks ' + hooksColor + hooksEnabled + c.reset + '/' + c.brightWhite + hooksTotal + c.reset);
|
|
opsParts.push(intellColor + '🧠 ' + intelligencePct + '%' + c.reset);
|
|
opsParts.push(c.brightCyan + '💾 ' + memoryMB + 'MB' + c.reset);
|
|
// Health: one glyph when green, terse copy when there's something to act on.
|
|
if (healthAllGreen) {
|
|
opsParts.push(c.brightGreen + '🛡 ✓' + c.reset);
|
|
} else {
|
|
// #2776: STALE gets dim/gray (distinct from the actionable yellow of
|
|
// PENDING/IN_PROGRESS) so a stale pill visibly stops shouting for
|
|
// attention — the user can act on the "run ruflo security scan" prompt or
|
|
// ignore it without a permanently-yellow indicator.
|
|
if (secStatus === 'PENDING') opsParts.push(c.brightYellow + '🛡 scan pending' + c.reset);
|
|
else if (secStatus === 'IN_PROGRESS') opsParts.push(c.brightYellow + '🛡 scanning…' + c.reset);
|
|
else if (secStatus === 'ISSUES') opsParts.push(c.brightRed + '🛡 findings' + c.reset);
|
|
else if (secStatus === 'STALE') opsParts.push(c.dim + '🛡 scan stale' + c.reset);
|
|
else if (secStatus !== 'NONE' && secStatus !== 'CLEAN') opsParts.push(c.brightRed + '🛡 ' + secStatus.toLowerCase() + c.reset);
|
|
if (findings > 0) {
|
|
opsParts.push(c.brightRed + '⚠ ' + findings + ' finding' + (findings === 1 ? '' : 's') + c.reset);
|
|
}
|
|
}
|
|
lines.push(opsParts.join(' ' + c.dim + '·' + c.reset + ' '));
|
|
|
|
// ─── Line 3: promo / disclosure / insight ───────────────────────
|
|
// Colored by content kind so it reads as *what it is*, not as noise:
|
|
// disclosure → brightCyan (announcement / capability link)
|
|
// promotional → brightPurple (Cognitum sponsor spot)
|
|
// educational → yellow (a tip)
|
|
// insight → brightRed (environment/task-aware, local, actionable —
|
|
// distinct from remote content on purpose)
|
|
const promoRow = getPromoRow(d);
|
|
if (promoRow) {
|
|
const kind = (d && d.promo && d.promo.kind) || 'disclosure';
|
|
const promoColor = kind === 'promotional' ? c.brightPurple
|
|
: kind === 'educational' ? c.yellow
|
|
: kind === 'insight' ? c.brightRed
|
|
: c.brightCyan;
|
|
lines.push(promoColor + promoRow + c.reset);
|
|
}
|
|
|
|
// Trailing blank line so Claude Code's input prompt gets breathing room
|
|
// instead of butting directly against the last statusline row.
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
// ─── Funnel promo row (ADR-301) ─────────────────────────────────
|
|
// Allowlist for OSC 8 hyperlink targets. Ships in code (not in payload) so
|
|
// no message can smuggle a link to an unapproved host.
|
|
//
|
|
// The final destination hosts (cognitum.one / agentics.org) AND the
|
|
// click-redirect host are both allowlisted here: promo.ts routes every
|
|
// clickable message through the server-side click-redirect (ADR-311 §7)
|
|
// so promo_open + geo are captured before the 302 to the real target —
|
|
// so the OSC 8 link the renderer emits points at the redirect host, not
|
|
// the final destination directly.
|
|
const PROMO_LINK_HOSTS = new Set([
|
|
'cognitum.one', 'www.cognitum.one', 'docs.cognitum.one',
|
|
// agentics.org — OSS foundation, distinct sponsor domain. Kept in sync
|
|
// with messages.ts ALLOWED_URL_HOSTS.
|
|
'agentics.org', 'www.agentics.org',
|
|
// Click-redirect host (funnel.ruv.io once its TLS cert is live; the raw
|
|
// Cloud Run hostname is allowlisted too since event-transport.ts /
|
|
// message-transport.ts / attribution.ts currently point at it as a TEMP
|
|
// fallback while the domain mapping's cert provisions).
|
|
'funnel.ruv.io',
|
|
'cognitum-analytics-63rzcdswba-uc.a.run.app',
|
|
]);
|
|
|
|
// Emit OSC 8 hyperlinks unless the environment is known-broken. tmux mangles
|
|
// raw OSC 8 (see anthropics/claude-code#27047) — opt in via env if wrapped.
|
|
function terminalSupportsHyperlinks() {
|
|
if (process.env.CI || process.env.GITHUB_ACTIONS) return false;
|
|
if (process.env.TERM === 'dumb') return false;
|
|
if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_STATUSLINE_HYPERLINKS || ''))) return false;
|
|
if (process.env.TMUX && !process.env.RUFLO_STATUSLINE_HYPERLINKS_TMUX) return false;
|
|
return true;
|
|
}
|
|
|
|
// Wrap a label in an OSC 8 hyperlink escape sequence. Falls back to the raw
|
|
// label whenever the URL is not an allowlisted https target, when the terminal
|
|
// can't render hyperlinks, or when parsing fails — a broken link must never
|
|
// leave a raw URL or stray escape in the statusline output.
|
|
function safeTerminalLink(label, url) {
|
|
if (!terminalSupportsHyperlinks()) return label;
|
|
if (typeof url !== 'string' || url.length === 0) return label;
|
|
let parsed;
|
|
try { parsed = new URL(url); } catch { return label; }
|
|
if (parsed.protocol !== 'https:') return label;
|
|
if (!PROMO_LINK_HOSTS.has(parsed.hostname)) return label;
|
|
const cleanLabel = String(label).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '');
|
|
if (cleanLabel.length === 0) return label;
|
|
const ESC = '\u001b';
|
|
return ESC + ']8;;' + parsed.href + ESC + '\\' + cleanLabel + ESC + ']8;;' + ESC + '\\';
|
|
}
|
|
|
|
function getPromoRow(d) {
|
|
try {
|
|
if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
|
|
if (/^(0|false|off|no)$/i.test(String(process.env.RUFLO_FUNNEL || ''))) return null;
|
|
const promo = d && d.promo;
|
|
if (!promo || typeof promo.text !== 'string') return null;
|
|
// Strip control chars / ANSI / bidi overrides — promo copy is data and
|
|
// must never emit its own terminal sequences. Hard-cap length AFTER the
|
|
// strip; append an ellipsis when the cap fires so the row visibly reads
|
|
// as truncated instead of chopping a word mid-character (was: silent
|
|
// slice(0,100) that could produce output that looked like corrupt data).
|
|
const MAX_LEN = 100;
|
|
const sanitized = promo.text
|
|
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
|
|
;
|
|
const text = (sanitized.length > MAX_LEN ? sanitized.slice(0, MAX_LEN - 1).trimEnd() + '…' : sanitized).trim();
|
|
if (text.length === 0) return null;
|
|
// Split the label from the trailing "· manage: ruflo settings" instruction
|
|
// so each part gets styling that matches what it actually IS:
|
|
// 1. label — OSC 8 hyperlink + underline. A real clickable link.
|
|
// 2. "manage:" — dim. Just a connector word, no action implied.
|
|
// 3. "ruflo settings" — bold/bright, NOT underlined. This is a shell
|
|
// command the user TYPES, not a link they CLICK — a terminal can
|
|
// never safely execute a command from a click (that would let any
|
|
// server-served message run arbitrary commands), so we deliberately
|
|
// avoid the underline/OSC8 cues that imply "clickable". Bold+bright
|
|
// instead signals "this is the important bit — copy/type it".
|
|
// Educational tips have no manage tail and no URL — plain text through.
|
|
const manageIdx = text.indexOf(' · manage: ');
|
|
const label = manageIdx > 0 ? text.slice(0, manageIdx) : text;
|
|
const manageWord = manageIdx > 0 ? ' · manage: ' : '';
|
|
const command = manageIdx > 0 ? text.slice(manageIdx + manageWord.length) : '';
|
|
const UL_ON = '\u001b[4m';
|
|
const UL_OFF = '\u001b[24m';
|
|
const DIM_ON = '\u001b[2m';
|
|
const DIM_OFF = '\u001b[22m';
|
|
const BOLD_ON = '\u001b[1m';
|
|
const BOLD_OFF = '\u001b[22m';
|
|
const FG_BRIGHT_WHITE = '[97m';
|
|
// Reset FG to default so the caller's row-color code resumes coloring the
|
|
// rest of the row after the command portion. Without this the row-color
|
|
// escape wouldn't visibly re-apply because we already emitted an explicit FG.
|
|
const FG_DEFAULT = '[39m';
|
|
// Some hosts (Claude Code's Windows UI, cmd.exe, older mintty) don't
|
|
// render OSC 8 hyperlinks as clickable — the label just underlines and
|
|
// clicks do nothing. Append a "(domain)" suffix so the destination is
|
|
// visible/copyable everywhere. Wrap the suffix in OSC 8 too so terminals
|
|
// that DO support hyperlinks give users TWO click targets (label AND
|
|
// domain hint) instead of one — some Windows hosts render one but not
|
|
// the other depending on how the statusline row is parsed.
|
|
// Only for URLs (not educational tips), and only when the label doesn't
|
|
// already end in the domain to avoid duplication.
|
|
let visibleUrlHint = '';
|
|
if (promo.url) {
|
|
try {
|
|
const host = new URL(promo.url).hostname.replace(/^www\./, '');
|
|
// Strip the click-redirect wrapper so users see the FINAL destination,
|
|
// not funnel.ruv.io. If the URL is /v1/click/<id>?to=<encoded>, pull the target.
|
|
let displayHost = host;
|
|
try {
|
|
const to = new URL(promo.url).searchParams.get('to');
|
|
if (to) displayHost = new URL(to).hostname.replace(/^www\./, '');
|
|
} catch { /* not a click-redirect, keep the raw host */ }
|
|
if (displayHost && !label.toLowerCase().endsWith(displayHost.toLowerCase())) {
|
|
// safeTerminalLink returns the plain string if URL isn't allowlisted
|
|
// or the terminal can't do OSC 8 — either way the domain stays visible.
|
|
const clickableDomain = safeTerminalLink(displayHost, promo.url);
|
|
visibleUrlHint = DIM_ON + ' (' + clickableDomain + ')' + DIM_OFF;
|
|
}
|
|
} catch { /* malformed URL — omit hint, never break the row */ }
|
|
}
|
|
// "Entire row clickable" (user request) — wrap the whole assembled
|
|
// string in ONE OSC 8 hyperlink instead of just the label. The command
|
|
// portion keeps its bold + bright-white treatment (no underline) so it
|
|
// still VISUALLY reads as a shell command the user should type, not a
|
|
// link — but if the user clicks anywhere on the row (label, domain
|
|
// hint, connector, even the command text), the terminal opens the URL.
|
|
// Clicking DOES NOT execute the command; it just opens the target URL,
|
|
// which is safe. Terminals that ignore OSC 8 render the whole row as
|
|
// styled text and no click behavior — the previous fallback (visible
|
|
// domain suffix) still keeps the destination readable.
|
|
const wrapWholeRowInHyperlink = (assembled) => {
|
|
if (!promo.url) return assembled;
|
|
if (!terminalSupportsHyperlinks()) return assembled;
|
|
let parsed;
|
|
try { parsed = new URL(promo.url); } catch { return assembled; }
|
|
if (parsed.protocol !== 'https:') return assembled;
|
|
if (!PROMO_LINK_HOSTS.has(parsed.hostname)) return assembled;
|
|
const ESC = '';
|
|
return ESC + ']8;;' + parsed.href + ESC + '\\' + assembled + ESC + ']8;;' + ESC + '\\';
|
|
};
|
|
// Visual styling stays per-part. We only add the OSC 8 wrap around the
|
|
// combined string, so the whole row is one click target.
|
|
const labelStyled = promo.url ? UL_ON + label + UL_OFF : label;
|
|
if (!command) return wrapWholeRowInHyperlink(labelStyled + visibleUrlHint);
|
|
return wrapWholeRowInHyperlink(
|
|
labelStyled + visibleUrlHint
|
|
+ DIM_ON + manageWord + DIM_OFF
|
|
+ BOLD_ON + FG_BRIGHT_WHITE + command + FG_DEFAULT + BOLD_OFF
|
|
);
|
|
} catch (e) {
|
|
return null; // the promo row must never break the statusline
|
|
}
|
|
}
|
|
|
|
// JSON output — delegates to CLI for accuracy; caller can use --json flag
|
|
function generateJSON() {
|
|
const d = getStatuslineData();
|
|
const git = getGitInfo();
|
|
return Object.assign({}, d, {
|
|
user: Object.assign({ name: git.name, gitBranch: git.gitBranch }, d.user || {}),
|
|
git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
|
|
lastUpdated: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
// ─── Main ───────────────────────────────────────────────────────
|
|
if (process.argv.includes('--json')) {
|
|
console.log(JSON.stringify(generateJSON(), null, 2));
|
|
} else if (process.argv.includes('--compact')) {
|
|
console.log(JSON.stringify(generateJSON()));
|
|
} else {
|
|
console.log(generateStatusline());
|
|
}
|