Commit Graph

17 Commits

Author SHA1 Message Date
rUv f35c545fbe feat(metaharness): pull in @metaharness/turn-credit + fix stale router/darwin pins (#2958)
* feat(metaharness): pull in @metaharness/turn-credit + fix stale router/darwin pins (post metaharness#176)

metaharness#176 shipped @metaharness/turn-credit (ADR-248, recursive
turn-level credit assignment) and bumped darwin to 0.9.0 (ADR-249 signal
seams) + router to 0.4.0 (calibration module). This brings ruflo's
dependency contract back in sync and makes the new package available:

- Add @metaharness/turn-credit ~0.1.0 as a new optionalDependency,
  following the same "must be installable, not peer-only" pattern
  darwin/flywheel/radio already use (ADR-150) — dependency-free, 64.9K
  unpacked, zero lifecycle scripts, same profile as the other three.
- Bump @metaharness/darwin ~0.8.3 -> ~0.9.0 and the paired MH_DARWIN_PIN
  constant in distill-oracle.ts (was already out of range: tilde only
  absorbs patches, and 0.9.0 is a minor bump).
- Bump @metaharness/router peer range ^0.3.2 -> ^0.4.0 (still deliberately
  peer-only + triple-gated behind CLAUDE_FLOW_ROUTER_NEURAL=1, per
  neural-router.ts — that design choice is unchanged, only the stale
  range is fixed) and update the matching manual-install hint in neural.ts.
- scripts/check-metaharness-pins.mjs + scripts/metaharness-clean-install-test.mjs:
  add turn-credit to the watched/contract-checked package list.
- .github/workflows/no-cli-optdep-bloat-2561.yml: CLI_MAX 10 -> 13. The
  prior bump (PR #2956) left zero slack (budget == count exactly), which a
  code review flagged as a latent trap — the very next unrelated optional
  dep would trip this guard. This bump leaves real headroom (11 declared
  today, budget 13) instead of repeating that mistake.
- Also fixes a live ReferenceError in distill-oracle.test.ts (MH_DARWIN_PIN
  used but never imported) — a gap from the prior release's test fix that
  somehow didn't surface in that PR's CI; caught here while touching the
  same file.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix: regenerate v3/pnpm-lock.yaml — was out of sync with package.json edits

The previous commit edited v3/@claude-flow/cli/package.json directly
(darwin/router/turn-credit pin changes) without regenerating the pnpm
workspace lockfile, so every CI job running `pnpm install --frozen-lockfile`
failed immediately with ERR_PNPM_OUTDATED_LOCKFILE — cascading into every
downstream smoke/test job that depends on that install step.

Regenerated with pnpm@8.15.9 (matching CI's pinned version) in an isolated
worktree to avoid the lockfile-version drift a newer local pnpm would
introduce.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-08-10 15:08:06 -02:30
rUv 4af1cfdded chore(release): bump to 3.35.0 — MetaHarness dependency-contract fix + ADR-381 sequential-evidence governance (#2957)
Minor bump: this release folds in PR #2956 (MetaHarness hardening — repair
the dependency contract, strict sequential promotion evidence, ADR-381) plus
a follow-up commit on that PR fixing 3 concurrency/epoch-boundary bugs found
during review that could silently violate the <=5% family-wise
false-promotion guarantee. All backward-compatible additions (new
optionalDependencies, new doctor checks, new CLI/MCP surface) — no breaking
changes, hence minor not major.

Also fixes a stale test assertion in distill-oracle.test.ts: the darwin
bench-run command now embeds the pinned @metaharness/darwin version
(MH_DARWIN_PIN) per the #2561 cold-npx regression guard, so the test's bare
'darwin bench run' substring check no longer matched the intentional pinned
output ('darwin@0.8.3 bench run').
2026-08-10 11:48:19 -02:30
rUv 9cf769ccce feat: ship adaptive swarm and resolve top runtime issues (#2848)
* feat: ship adaptive swarm and issue fixes

* fix: register intentional runtime escape hatches
2026-07-29 13:25:22 -04:00
rUv f220882025 feat(daemon): repository supervisor, structured telemetry, budget CLI, migration warning (#2661) (#2663)
* feat(daemon): repository supervisor, structured telemetry, budget CLI, migration warning (#2661)

Closes the four gaps flagged as still-open against #2661's full root-fix
spec after the containment + dedup phases already shipped (#2662).

1. Repository-level supervisor + worktree leases (workspace-lease.ts,
   repo-supervisor.ts). Ten worktree daemons of one repository independently
   deciding "is it time to run audit" every tick was the redundant-scheduler
   half of the cardinality bug — the budget ledger and job dedup already
   bound and dedupe actual spend, but every daemon still ran its own timer.
   Exactly one daemon per repositoryId is now elected supervisor (lock-
   protected takeover, same pattern as global-ai-budget.ts) and owns the
   recurring AI-worker schedule; every other worktree stays a lease-only
   participant (15-min TTL heartbeat) running its cheap local-only workers.
   An explicit `daemon trigger --headless` still bypasses the gate — it's a
   one-off user action, still budget/dedup-governed like any other launch.

2. Structured per-launch token telemetry. `claude --print` now runs with
   `--output-format json`; parseClaudePrintJsonEnvelope() extracts input/
   output tokens, cost, and API duration, receipted via the budget ledger's
   new recordUsage(). Lenient parsing — any schema mismatch degrades to
   "no usage captured" rather than breaking existing analysis-output
   parsing, since the raw `result` text still flows through unchanged.

3. `ruflo daemon budget show|pause|resume`. Budget state was previously
   visible only inline in `daemon status --all`; these give it an
   independently scriptable surface. pause()/resume() reuse the exact same
   enforcement path as the automatic quota-triggered circuit breaker.

4. One-time upgrade migration warning. A pre-existing multi-daemon fleet
   where AI workers were already enabled (old config/env from before this
   fix) is exactly the P0 shape #2661 describes — warned once ever (a
   persisted marker, not per-command), and only for that specific risky
   shape, not a harmless AI-off fleet (already covered by the existing
   always-shown fleet-size notice).

`daemon status --all` gains a "Repository Supervisors" panel alongside the
existing budget panel.

54 new tests across workspace-lease, repo-supervisor, claude-print-envelope,
global-ai-budget (extended), and daemon-migration-warning-2661 suites.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(statusline): don't import helper-refresh.js just for getInstalledCliVersion

statusline-generator.ts imported getInstalledCliVersion from helper-refresh.js
(added in b254e3215, shipped 3.27.3-3.27.4). ES module imports load a
module's ENTIRE top-level regardless of which export is used — helper-
refresh.ts imports the `semver` package at module scope (for
autoRefreshHelpersIfStale()'s version comparison, unrelated to the function
actually needed here), so merely importing getInstalledCliVersion dragged
`semver` into every context that loads this generator.

Confirmed live: PR #2663's "Static regression guards" CI job installs only
minimal smoke deps (no full `npm install`), and helpers-generator.ts
transitively imports statusline-generator.ts — the router smoke test
(#2257) failed with ERR_MODULE_NOT_FOUND('semver') the moment this file
gained the import, despite never calling anything semver-related itself.
Verified by temporarily hiding node_modules/semver locally: reproduced the
exact failure, confirmed fixed after this change.

Duplicates the same createRequire/walk-up version resolution locally
(getInstalledCliVersionLocal) using only Node builtins — this generator's
own footprint should stay minimal since it's loaded in exactly these
lightweight/partial-install contexts. No output change: the baked-in
version value and the rest of the generated script are byte-identical.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-07-14 01:32:34 -04:00
rUv 215840536e fix(daemon): opt-in AI workers, global launch budget, cross-worktree dedup, stop --all (#2661) (#2662)
* fix(daemon): make AI workers opt-in, add user-global launch budget + stop --all (#2661)

Containment patch for the P0 worktree-daemon fanout: N worktree daemons
each auto-enabled headless claude --print workers whenever the Claude CLI
was on PATH, so autonomous quota use scaled linearly with worktree count
(13 launches/hour/daemon) and could silently exhaust a user's Claude quota.

- AI workers are now OPT-IN (invariant 1: default install → zero
  autonomous Claude launches). Consent via `daemon start --headless`,
  `daemon.aiWorkers.enabled: true` in .claude-flow/config.json, or
  RUFLO_DAEMON_AI_WORKERS=1. Without it the daemon never probes
  `claude --version` and every worker runs its $0 local path.
- `--headless` is now a real authorization gate: threaded into
  DaemonConfig.aiWorkersEnabled and re-checked in runWorkerLogic
  (defence in depth); consent is never restored from stale
  daemon-state.json. `daemon trigger --headless` grants per-run consent.
- New user-global AI budget (services/global-ai-budget.ts): every launch
  must atomically reserve a slot in a shared owner-only ledger under
  ~/.claude-flow before any process is created. Defaults:
  maxConcurrentGlobal 1, 2 launches/hour, 12/day. Quota/429 failures open
  a user-global circuit breaker (60 min) so ALL daemons stop retrying.
  Every launch/denial/pause emits a receipt (no prompts/source persisted).
  Escape hatch: RUFLO_AI_BUDGET_DISABLE=1; limits tunable via
  RUFLO_AI_MAX_PER_HOUR / _PER_DAY / _CONCURRENT.
- `daemon stop --all`: stops every ruflo daemon across all
  workspaces/worktrees (positive argv identification only), SIGTERM-first
  so each daemon's shutdown path reaps its Claude process group, then
  removes only ruflo-owned PID files. `stop()` now cancels in-flight
  headless children; cancel/cancelAll signal the whole process group.
- `daemon start` warns when multiple daemons are running across
  worktrees; `daemon status` shows the AI-consent state of the RUNNING
  daemon; `daemon status --all` shows global AI budget usage and the
  circuit-breaker state.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01AHD8okFGN5JhCgu1MJmeUS

* feat(daemon): repository identity, cross-worktree job dedup, worktree-removal shutdown (#2661)

Root-fix phase 2 for the worktree daemon fanout — separates worktree
identity from repository identity and stops duplicate model work across
worktrees (issue invariants 5 and 6, containment form).

- New git-workspace-identity service: resolves worktreeRoot
  (rev-parse --show-toplevel), commonGitDir (rev-parse --git-common-dir),
  a stable repositoryId (sha256 of the canonical common git dir — shared
  by ALL worktrees of one repository), and HEAD. Non-git dirs degrade to
  a path-hash id. Stable parts cached per process; HEAD never cached.
- New ai-job-dedup registry (~/.claude-flow/ai-jobs.json, owner-only,
  symlink-rejecting): before a model launch the executor computes
  jobKey = sha256(repositoryId, HEAD, workerType, configHash) and skips
  the launch when the same job succeeded within the freshness window
  (the worker's own interval, floor 10 min; RUFLO_AI_DEDUP_WINDOW_SECS
  to tune, RUFLO_AI_DEDUP_DISABLE=1 to turn off). HEAD moves -> new key
  -> job runs again. Dedup is best-effort UNDER the budget: the atomic
  budget reservation remains the hard launch bound. Skips happen before
  budget spend, return dedupSkipped results, and never overwrite
  persisted metrics or fall back to redundant local runs.
- Lifecycle monitor now always runs: a daemon whose workspace/worktree
  directory was removed self-shuts within one 60s check interval, even
  with ttl/idle disabled (previously the monitor was skipped entirely
  when both were 0). Tick logic extracted into a testable predicate.
- daemon status --all budget panel now attributes 24h launches per
  workspace, so the worktree spending the shared budget is identifiable.

Verified end-to-end with two real git worktrees: a success recorded at
HEAD in worktree A makes worktree B's executor skip pre-spawn with zero
budget spend; a new commit in B bypasses dedup and proceeds to the
budget gate.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01AHD8okFGN5JhCgu1MJmeUS

* fix(security): register #2661's new daemon/dedup env vars in the precedence audit

RUFLO_AI_DEDUP_DISABLE and RUFLO_AI_DEDUP_WINDOW_SECS are background daemon
tuning knobs with no CLI command surface to attach a flag to. RUFLO_DAEMON_AI_WORKERS
does have CLI-flag precedence (`daemon start --headless`), but it's wired via
constructor-injected config in commands/daemon.ts rather than a same-file
check the audit's local-context heuristic can see from worker-daemon.ts,
where the env read actually lives.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 23:00:41 -04:00
rUv d0ac73d798 feat: Cognitum customer lifecycle funnel — ADR-301..310 + implementation (#2622)
* docs(adr): add ADR-301..305 — Cognitum customer lifecycle funnel

New 300-series ADRs defining the ruflo → cognitum.one conversion funnel:

- ADR-301: promotional status surface (rotating bottom status row;
  reaches existing installs via the ADR-174/177 signed helper
  auto-refresh channel)
- ADR-302: one-time post-init capability enrollment prompt
- ADR-303: contextual credit-exhaustion recovery/upgrade experience
- ADR-304: optional local Meta LLM proxy (localhost:11435 →
  api.cognitum.one, OpenAI-compatible, local-first)
- ADR-305: funnel overview, design principles, success + guardrail
  metrics

Also indexes the new series in the ADR README.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013FqZGy1zZ49JivKUSyqDKH

* docs(adr): harden ADR-301..305 per review — trust and funnel-integrity gates

- ADR-301: signed content boundaries — messages are schema-validated
  data only, length-bounded, URL-allowlisted, expiry-cached, and
  sanitized so copy can never emit terminal control sequences
- ADR-302: split enrollment into four independent consent domains
  (account, proxy-install, telemetry, cloud-routing) with versioned
  consent receipts; acceptance authorizes auth login only
- ADR-303: deterministic error taxonomy with explicit provider codes
  and a fail-closed classifier; no text-matching of provider messages
- ADR-304: mandatory data-plane disclosure before cloud routing;
  proxy installs local-only, cloud routing off until explicit opt-in
- ADR-305: attribution rules (event vocabulary, anonymous rotating
  funnel ID, 7/30-day attribution windows, 90-day retention,
  verifiable deletion), ordered gate hierarchy with hard integrity/
  health/trust gates, automatic circuit breaker via the signed config
  channel, and an end-to-end acceptance test for existing installs

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013FqZGy1zZ49JivKUSyqDKH

* docs(adr): amend ADR-301/303/304/305, add ADR-306..310 — deployable funnel system

Amendments:
- ADR-301: existing-install disclosure gate (never promo before
  disclosure, once per user, decline disables all surfaces), 4:1
  educational:promotional content ratio, 30-min repeat cap, no dark
  patterns, marquee off for screen readers/reduced motion, control
  precedence chain
- ADR-303: Cognitum account service as sole credit authority
  (GET /v1/credits), canonical CreditErrorCode enum; only
  COGNITUM_CREDIT_EXHAUSTED triggers the funnel surface
- ADR-304: retitled as the proxy *product* ADR; explicit relationship
  to the internal metallm dev-bridge (shared routing core, versioned
  compat layer, no implicit contract dependency); runtime deferred
  to ADR-307
- ADR-305: kill-switch claim corrected — release-bound vs opt-in
  freshness mechanisms with honest latencies, 24h revocation TTL,
  last-valid-signed-policy failure mode; normative control precedence
  (env > enterprise > user > default > remote); two measurement
  planes (CI release qualification enforces gates; production
  analytics measures adoption only)

New ADRs:
- ADR-306: Cognitum auth — PKCE + device flow, keychain-only refresh
  tokens, incremental scopes mapped 1:1 to consent domains
- ADR-307: proxy runtime — Rust single binary, loopback bind +
  per-user token, platform user services, explicit lifecycle
  commands, signed no-self-update packaging
- ADR-308: versioned public API contract — OpenAPI in both repos,
  idempotent event ingestion, normative client failure policy
- ADR-309: governance — npm-policy commitments, GDPR/CCPA basis,
  constrained closed event schema with timestamp bucketing, content
  approval ownership
- ADR-310: staged rollout phases 0-6, promotion criteria, three-
  package rollback discipline, CI gate matrix, final acceptance test

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013FqZGy1zZ49JivKUSyqDKH

* feat(funnel): implement ADR-301/302/303/305 funnel system with release gates

Core module (v3/@claude-flow/cli/src/funnel/, local-only, zero network I/O):
- precedence.ts: ADR-305 control chain (env > enterprise policy > user
  config > project config > package default > remote policy); a lower
  source can never re-enable a higher disable
- disclosure.ts: ADR-301 existing-install disclosure gate (never_seen /
  disclosed_enabled / disclosed_disabled), 72h grace window before any
  message, decline disables all surfaces
- messages.ts: signed-content boundaries: schema validation, 80-col
  grapheme-aware bound, exact-host URL allowlist, expiry, and hard drop
  of any C0/C1/ANSI/OSC/bidi sequence (drop, never repair)
- rotation.ts: deterministic scheduler; promo only in 1-of-5 slots,
  30-min repeat cap, educational fills all other slots
- consent.ts: ADR-302 versioned receipts for 5 separate domains; grant
  AND decline recorded; stale policyVersion means re-ask, never carry
  forward
- credit-errors.ts: ADR-303 CreditErrorCode enum + fail-closed classifier
  over machine-readable provider codes (never message text); only
  COGNITUM_CREDIT_EXHAUSTED fires the recovery surface, once per session
- events.ts: ADR-309 closed event schema, daily timestamp buckets,
  bounded local queue, telemetry-consent-gated, opt-out deletes ID+queue
- enrollment.ts + promo.ts: surface orchestrators

Surface integrations:
- hooks statusline --json now computes a promo field (all gates run
  server-side; failures never break the statusline)
- generated statusline.cjs renders the bottom promo row with
  defense-in-depth re-sanitization + env/CI gates at render time;
  repo-root artifact regenerated (drift guard green)
- init: one-time post-init capability enrollment (--no-signup flag,
  CI/non-TTY auto-skip, records only the account consent domain)
- new command: ruflo funnel status|disable|enable|id
- doctor gains a funnel component reporting effective state + deciding
  precedence source (enterprise audit verification)

Contracts and gates:
- v3/docs/api/cognitum-v1.openapi.yaml: ADR-308 v1 contract (8 endpoints,
  error taxonomy mirrored 1:1, normative failure policy, signed policy
  feed schema with 24h TTL cap)
- .github/workflows/funnel-gates.yml: ADR-310 hard gates: invariant
  suite + runtime suppression proofs (promo in CI = 0, RUFLO_FUNNEL=0
  suppression, opt-out persistence, non-interactive init)
- __tests__/funnel.test.ts: 50 tests mapping 1:1 to the ADR-310 gate list

Verified: cli-core/swarm/memory/neural/security/cli builds green; funnel
suite 50/50; statusline-cost-display drift guard + runtime 8/8;
init-wizard-bugs and p1-commands green; live smoke of the funnel
lifecycle, statusline promo gates (fresh shows disclosure, CI shows
nothing, RUFLO_FUNNEL=0 shows nothing, post-grace rotates educational),
doctor component, and init under CI.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013FqZGy1zZ49JivKUSyqDKH

* fix(funnel-gates): mirror the green Build V3 setup (pnpm 8, frozen lockfile, workspace build)

The runtime-suppression job failed on the PR because pnpm 9 with a
filtered install resolved the bare-semver workspace deps
(@claude-flow/cli-core etc.) from the npm registry instead of linking
the workspace packages, so the cli tsc build could not find cli-core's
dist. Copy the exact setup the passing Build V3 job in v3-ci.yml uses:
pnpm/action-setup@v6 pinned to pnpm 8, pnpm install --frozen-lockfile
over the full workspace, and a workspace-wide pnpm build. Also install
pyyaml explicitly before the OpenAPI parse check.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013FqZGy1zZ49JivKUSyqDKH

* chore(helpers): pending-insights runaway-storage guard in intelligence.cjs

Captured from the session's self-learning hook runtime: recordEdit now
caps the append-only pending-insights file at ~512KB by keeping the most
recent 2000 lines when consolidation has not drained it, so it can never
grow unbounded. Non-fatal, statSync-per-edit cost only.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013FqZGy1zZ49JivKUSyqDKH

* feat(funnel): rotating sponsor disclosure, statusline redesign, auto-update

## Statusline (client render)

Reorganized 4 rows by purpose (Architecture / Runtime / Health / AgentDB):
- suppress zero/healthy fields; glyph-only for 🧠 memory % and 💾 RAM MB
- drop DDD % duplicate, drop ADR denominator, drop opaque "◆ DB" marker
- "Target 150x-12500x" → "Goal 150x-12500x" (plain language)
- Health row hides on NONE; collapses to "✓ Security ✓ No CVEs" when clean,
  reads "N vulnerabilities" (not "CVE checks 0/3") on unfixed findings
- kind-based row color: brightCyan for disclosure, brightPurple for
  promotional sponsor, yellow for educational — reads as *what it is*

## Funnel promo row (ADR-301/305/309)

- Rotating disclosure: 3 copy variants on a deterministic 5-min slot with a
  shared Cognitum sponsor URL. Neutral framing — no "Sponsored by"/
  "Powered by" (would read as advertising and lose OSS trust).
- OSC 8 hyperlink wrapper: allowlist ships in renderer (cognitum.one hosts
  only), https-only, TMUX/CI/TERM=dumb suppression, kill-switch env var,
  defense-in-depth label re-sanitization. Broken URL / non-https /
  non-allowed host → plain label, never a raw escape.
- Attribution: attributionUrl() decorates every outbound funnel link with
  utm_source=ruflo · utm_medium=statusline · utm_campaign=<kind> ·
  utm_content=<msg-id>. Client-side link builder, ZERO runtime network
  call — API-down has no effect on render. `fid` (pseudonymous funnel ID)
  appended only under telemetry consent (ADR-309 privacy default).
- Defense in depth: attributionUrl rejects non-https schemes at build time
  in addition to renderer's OSC 8 allowlist.

## Auto-update for existing installs

- statusline.cjs added to CRITICAL_HELPERS + executor.ts + sign-helpers.mjs.
- helper-refresh.ts fallback generates statusline.cjs when the installed
  package isn't resolvable.
- Regen script scripts/regen-statusline-artifact.mjs keeps root artifact in
  lockstep with the generator (drift-guard test enforces).

PUBLISH-TIME REQUIRED (only ruvnet-owned key can do): re-run
scripts/sign-helpers.mjs with the Ed25519 signing key so
helpers.manifest.json includes statusline.cjs's SHA-256. Without it,
auto-refresh fail-closes (intended: tampered helper refuses install).

## Tests: 50 → 57 (+ statusline-cost-display drift guard passes)

- parameterized invariant over every DISCLOSURE_TEXTS variant
- sponsor URL shape (https + cognitum.one)
- 5-min rotation determinism
- attributionUrl: UTM shape, absent fid without consent, non-https rejection,
  malformed base URL verbatim
- API-down design lock: promo.ts contains no fetch/https/XMLHttpRequest

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(statusline): promo row flicker — stale-while-revalidate cache

The funnel promo row would appear on cache warm-up then vanish on the next
render whenever the 60s cache TTL expired mid-session and the CLI call
either failed, timed out, or was resolved to a bin that returned no promo.
Root cause: getStatuslineData() fell through to buildLocalFallback() on CLI
failure, and the fallback returned a stub object with no `promo` field.

Fix: stale-while-revalidate on the /tmp cache.

- readCache() now returns { fresh, data }. The TTL only decides whether the
  data is fresh; the payload comes back either way.
- getStatuslineData(): fresh cache → return immediately. Else try the CLI.
  On CLI failure, if we have any prior cache data, re-apply local overlays
  and return it. Only if we've never had a good CLI response do we fall to
  buildLocalFallback().

Effect: the promo row survives CLI hiccups, timeouts, and cache-expiry
mid-render. Live verified: cache backdated 5 min, all 3 CLI bins disabled
— row still renders the last known disclosure.

Test: pins the generator template shape so a future edit breaking the
readCache freshness split or the stale-fallback path trips CI. 57 → 58.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(statusline): 3-line design + persistent promo memo

Two related fixes for observed live-session bugs.

## 3-line design (promo as line 1)

Claude Code truncates the statusline past line 4 with the system-guidance /
input-prompt line. The previous 6-row layout (header + separator + 4
purpose rows + promo) put the funnel row on line 6, so it was silently
dropped by the viewport.

New layout, exactly 3 lines:
  Line 1 — Promo / disclosure (funnel row, ADR-301 surface)
  Line 2 — Header (version · git · model · timing · ctx · cost)
  Line 3 — Compressed ops (Swarm · Hooks · 🧠 · 💾 · Health)

Everything on the previous 4 individual rows is now on the ops line, dot-
separated. Diagnostic detail (Domains, ADRs count, vectors, MCP count,
DB marker) moves behind `ruflo status --verbose` per the earlier design
discipline you documented ("Show healthy state only when it confirms
readiness. Show zero values only when zero is actionable. Show failures
immediately.").

## Persistent promo memo

Even with stale-while-revalidate cache, promo would blink out when an
older `@claude-flow/cli` cached by npx succeeded but returned JSON with
NO promo field. Cause: the older version predates ADR-301, so its
statusline JSON schema is missing our field. The cache path only fires
when the CLI THROWS — a promoless success clean-writes.

Fix: `~/.ruflo/statusline-promo.json` — a 6h-TTL memo of the last known
good promo. Every successful render writes it. Every code path
(fresh-cache / CLI-success / stale-cache / cold-fallback) calls
`overlayMemoPromo(data)` which injects the memo IFF `data.promo` is
missing. The funnel row now survives:
  - fresh cold cache + working CLI     ← memo not needed
  - stale cache + CLI timeout          ← stale-while-revalidate path
  - cache wiped + CLI down             ← memo overlay (proven live)
  - working CLI, promoless response    ← memo overlay (the new fix)

Live proof: cache wiped, all 5 known `cli.js` bins renamed to
`.disabled`, render still emits the 3-line output with promo on line 1.

## Tests: 58 → 60 in funnel suite, 66 → 68 total

Pinned in the generator template:
  - 3-line ordering (promo push before header push before ops push)
  - PROMO_MEMO_FILE + readPromoMemo + overlayMemoPromo helpers exist
  - overlay is called on every code path (spot-checked by call count)

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(statusline): 3-line order — RuFlo header · ops · promo (top-to-bottom)

Per user preference: RuFlo header must be line 1 (not the promo/disclosure
row). Reordering the render:
  Line 1 — Header  (▊ RuFlo · git · model · timing · ctx · cost)
  Line 2 — Ops     (Swarm · Hooks · 🧠 · 💾 · Health)
  Line 3 — Promo   (funnel disclosure / educational tip / sponsor)

Promo stays within the visible 3-line viewport so Claude Code doesn't
truncate it with the system-guidance line. Memo + stale-while-revalidate
safety net from the previous commit is unchanged — promo remains visible
even when the CLI returns a promoless response.

Test invariant updated to pin the new order (header push < ops push <
promo push in the generator source). 68/68 tests still pass.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(statusline): underline the promo label as a clickable CTA

The sponsor / disclosure row currently reads as flat text — users don't
know it's clickable. Two visual affordances added:

1. ANSI underline ([4m / [24m) on the label portion so terminals show it
   as a clickable link even when they don't render OSC 8 hyperlinks (a
   universal cue: underline = interactive text). Combined with the
   existing OSC 8 wrap, modern terminals get a real hyperlink; legacy
   terminals get a visual affordance.

2. The trailing "· disable: ruflo funnel disable" instruction now renders
   dim + non-underlined + outside the OSC 8 wrap. It reads as metadata
   ("here's how to turn it off") rather than as part of the link target
   (which would falsely imply that clicking anywhere on that text goes to
   Cognitum).

Split happens on the exact anchor " · disable" — educational tips with
no disable tail keep their whole text rendered plainly (no underline,
no OSC 8, since they have no URL).

Test locks the split-and-underline pattern in the generator template so
a future refactor that reunifies the row trips CI.

60 → 61 funnel tests · 68 → 69 total.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(funnel): ADR-308 event transport + Cloud Function reference impl

Phase 2 client-side telemetry + reference server. Wires the funnel event
queue (v3/@claude-flow/cli/src/funnel/events.ts already exists) to the
Cognitum analytics endpoint via a fail-silent HTTP batcher, plus an
out-of-band credit-exhaustion notifier.

## Client transport (v3/@claude-flow/cli/src/funnel/event-transport.ts)

ADR-308 contract:
- POST /v1/events with Idempotency-Key header (UUIDv4 per batch)
- Consent-gated: zero network activity when `telemetry` consent is off
- Bounded batches (MAX_BATCH = 100), exponential backoff on failure, capped
  at 30x base interval (~30 min)
- 4s POST timeout — telemetry never stalls the CLI
- Rejects non-https endpoints inside the transport (defense in depth on
  top of the client-side allowlist)
- Credit-exhaustion detection: 402 response OR body containing the exact
  ADR-303 machine-readable code triggers the notifier

## Credit notifier (v3/@claude-flow/cli/src/funnel/credit-notifier.ts)

Persistent user-visible flag at ~/.ruflo/credit-status.json:
- markCreditExhausted() — idempotent, stable `since` timestamp
- clearCreditStatus() — stamps `cleared`, drops the exhausted flag
- creditExhaustedNotice() — user-facing single line with humanized age
- readCreditStatus() — inspector for status/tools

## Server reference (services/cognitum-analytics/)

Cloud Function reference implementation:
- index.js — POST /v1/events handler, Firestore writes to funnel_events
  + funnel_aggregates + funnel_credit + funnel_idem collections
- package.json — Node 22 + functions-framework + Firestore SDK
- deploy.sh — gcloud CLI deploy (gen2, us-central1, 402-on-cap ceiling)
- sample-batch.json — fixture for smoke tests
- README.md — layout, local run, deploy, verification

DEPLOY IS HUMAN-DRIVEN. The autonomous loop does not run deploy.sh —
that requires a live gcloud session with cloudfunctions.admin on
cognitum-20260110 and is outside the hard-fence for automated actions.
Everything up to the deploy is scripted; the human runs `./deploy.sh`
when ready.

## Tests: 68 total funnel tests (61 → 68, +7 new)

- Transport defaults (https + batch cap + backoff floor + timeout ceiling)
- No-consent no-op (zero network activity)
- Non-https endpoint rejected inside transport
- markCreditExhausted idempotence (stable `since`)
- clearCreditStatus stamps `cleared`
- creditExhaustedNotice humanized age copy
- Null return when not exhausted (no false surface)

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(funnel): funnel.ruv.io domain mapping for analytics endpoint

The cognitum-analytics Cloud Function is now deployed on
cognitum-20260110 and reachable at funnel.ruv.io/v1/events (Cloud Run
domain mapping) as well as the raw Cloud Run URL. The client's
DEFAULT_ENDPOINT points at the ruv.io subdomain so it:

  1. survives Cloud Run hostname-hash changes on redeploy,
  2. keeps analytics attribution on rUv's own domain (not cognitum.one,
     which is a separate product surface),
  3. reads honestly to users: telemetry for the OSS CLI lives with the
     OSS author, not with the cognitum.one commercial product.

DNS is a Cloudflare CNAME funnel.ruv.io → ghs.googlehosted.com,
unproxied so Cloud Run terminates TLS directly. README documents the
recreate-from-scratch flow using the CLOUDFLARE_API_TOKEN in Secret
Manager on cognitum-20260110.

Live infrastructure state:
- Cloud Function cognitum-analytics deployed us-central1, revision 00001
- Firestore collections funnel_events / funnel_aggregates /
  funnel_credit / funnel_idem writing on first accepted batch
- Smoke test at deploy time returned {"ok":true,"accepted":1}
- Domain mapping created, DNS resolving, cert provisioning pending
  (Cloud Run polls hourly)

Co-Authored-By: RuFlo <ruv@ruv.net>

* docs(adr-311): funnel analytics — server-side split to dedicated repo

Server implementation of the ADR-308 /v1/events endpoint has been extracted
to its own repo: github.com/cognitum-one/ruflo-funnel-api

Reasoning captured in ADR-311:
- Commercial-side changes (rate limits, tenant model, BigQuery export)
  don't churn the OSS CLI PR history.
- ADR-308 client contract remains the single source of truth for the wire
  format; the server can be forked or swapped without touching the CLI.
- Server security surface (Firestore rules, IAM, key rotation) belongs
  with whoever runs the endpoint, not tangled with CLI release cadence.

Also captures the concrete deployment decisions that came out of standing
up the endpoint against cognitum-20260110:
  - Runtime: Cloud Function gen2, Node 22, us-central1, 256 MiB
  - Domain: funnel.ruv.io (Cloud Run mapping, Cloudflare CNAME unproxied)
  - Storage: 4 Firestore collections (funnel_events / _aggregates /
    _credit / _idem) with ADR-309 retention rules
  - Credit signal: HTTP 402 + body 'COGNITUM_CREDIT_EXHAUSTED' wires
    directly into the ADR-303 recovery surface via credit-notifier.ts

services/cognitum-analytics/ in this repo is now a pointer README to the
dedicated repo; the four source files were verbatim-committed as the
initial commit there.

Live-verified 8/8 API contract tests + Firestore writes across all 4
collections at time of adoption.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(funnel): remote-served message rotation + agentics.org sponsor + settings command

Three linked changes to the funnel rotation surface.

## Remote-served messages (ADR-311 amendment)

New client transport `message-transport.ts`:
- GET https://funnel.ruv.io/v1/messages (raw:
  https://cognitum-analytics-63rzcdswba-uc.a.run.app/v1/messages)
- 6h TTL local cache at ~/.ruflo/funnel-messages-cache.json
- Every fetched message validated through ADR-301 isValidMessage() — schema,
  host allowlist, control-char strip, 80-col cap — before it can display
- Best-effort: fetch failures leave cache intact; no network call blocks
  render (fire-and-forget from rotation.selectMessage)
- Kill switch: RUFLO_FUNNEL_MESSAGES=0 or RUFLO_FUNNEL=0
- Bounded cache: ≤ 128 KiB, ≤ 200 messages
- Non-https rejected at builder AND host allowlist (defense in depth)

New pool merger `eligibleMessagesFromPools()` in messages.ts:
- Remote pool authoritative (admin edits without a CLI release)
- In-code pool fallback (cold starts, API-down, kill-switched)
- Dedup by `id` — remote wins over in-code for the same id

Rotation kicks a background refresh on every selectMessage() call so
the first fresh render after CLI startup lands within seconds.

## Server side (cognitum-one/ruflo-funnel-api@521205a)

- Added GET /v1/messages handler (single router by req.path in the same
  function; POST /v1/events unchanged)
- Firestore collection funnel_messages with { id, class, text, url?, active }
- Cache-Control: public, max-age=21600 for CDN + client caches
- Firestore seeded with 23 initial messages (via seed-messages-rest.sh):
    16 educational  (ruflo tips + tricks)
     4 promotional  Cognitum
     3 promotional  agentics.org
- Verified live: 200 OK, 23 messages, all 0 rejected by client pipeline

## Copy discipline + settings command

- ALLOWED_URL_HOSTS + PROMO_LINK_HOSTS gain agentics.org (both wire layers
  need it for OSC 8 to render the link)
- DISCLOSURE_TEXTS copy: "· disable: ruflo funnel disable" →
  "· manage: ruflo settings" — user-facing command surface no longer leaks
  the internal "funnel" term
- New `ruflo settings` command (settings.ts) as a friendly wrapper over
  the existing funnel primitives:
    ruflo settings                     Overview + current state
    ruflo settings notices status      Notices row state
    ruflo settings notices off         Turn off (persistent)
    ruflo settings notices on          Re-enable
    ruflo settings notices id          Pseudonymous notices ID
- getPromoRow split anchor moved from " · disable" → " · manage" (so only
  the linked label is OSC 8 wrapped, tail stays dim/plain)

## Tests: 68 → 76 (+8)

- Every disclosure variant: no `\bfunnel\b`, contains `manage`, contains
  `ruflo settings`, ≤ 80 cols, Cognitum-attributed
- Split anchor in generator: " · manage" (not " · disable")

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(funnel): impressions + click tracking + coarse geo (ADR-311 amend)

Adds the analytics coverage that PR #2622's initial funnel work deferred:
impressions, clicks, and coarse geo. Consent-gated end-to-end; nothing
new about privacy — same closed vocabulary, same daily-bucket timestamps,
same pseudonymous UUID discipline. Wired against the live Cloud Function
on cognitum-20260110 + Firestore.

## Client — impression firing + click-tracked URLs

- FunnelEventName vocabulary gains `promo_impression` + `promo_open`
  (matches server; ADR-305 amendment recorded in ADR-311 §6)
- FunnelEvent schema gains optional `messageId` (≤ 64 chars, [a-z0-9-])
  — the join key across impression → click → conversion
- recordFunnelEvent() accepts an options object for `messageId` +
  `now` (backwards-compat with the Date positional form)
- rotation.selectMessage() fires `promo_impression` for the selected
  message on every render — consent-gated, batched via existing transport
- attribution.clickTrackedUrl() wraps promotional URLs in the server-side
  click-redirect so `promo_open` fires before the browser leaves.
  Disclosure/educational rows still use attributionUrl direct (no click
  tracking on disclosure)
- promo.ts uses clickTrackedUrl for `msg.class === 'promotional'`

## Server (cognitum-one/ruflo-funnel-api@37f584b — deployed live)

- KNOWN_EVENTS gains promo_impression + promo_open
- validEvent() accepts optional messageId via MESSAGE_ID_RE allowlist
- New `GET /v1/click/{messageId}?to=<https-url>` handler:
    - messageId validated against MESSAGE_ID_RE (prevents path-traversal
      or forged doc lookups)
    - `to` must be https AND host must be in CLICK_ALLOWED_HOSTS
      (cognitum.one + agentics.org variants — compiled in, not
      Firestore-editable; no open-redirect vector)
    - Coarse geo from CF-IPCountry / X-Appengine-Country (ISO-3166 alpha-2
      country ONLY — never city, never lat/long; ADR-309 privacy invariant)
    - Firestore write: funnel_events doc + funnel_aggregates row keyed by
      (surface, event, day, release, messageId, country)
    - Firestore hiccup NEVER blocks the redirect — user intent > analytics
    - 302 with Cache-Control: no-store so aggregators see every click
- Router extended to match /v1/click/{msgId} paths

## Verified live at commit

| Test | Result |
|---|---|
| Click w/ good target + fake CF-IPCountry:US | 302 → cognitum.one; Firestore captured US |
| Click w/ non-allowed target (evil.com) | 400 DISALLOWED_TARGET |
| Click w/ http:// target | 400 DISALLOWED_TARGET |
| Click w/ `..` path traversal | 405 (routed as method-not-allowed) |
| POST promo_impression event | 200 accepted:1 |
| POST promo_open event | 200 accepted:1 |
| POST event w/ malformed messageId | 200 accepted:0 dropped:1 |

Firestore verified: promo_open with `country: US`, `via: click-redirect`,
`messageId: promo-cognitum-meta-llm`. Aggregate row present with
`count: 1` keyed by messageId+country.

## Coverage summary (ADR-311 §8)

- Impressions per message per day: `promo_impression` events + aggregates
- Clicks per message per day: `promo_open` events + aggregates
- CTR: ratio of the two above
- Geo distribution: `country` field on `promo_open`
- Conversions: existing `signup_opened` / `account_created` / `proxy_activated`
- Notices disabled rate: existing `funnel_disabled`

Zero PII, zero prompt content, zero paths — everything remains closed enum,
allowlisted message id, ISO country code, or daily bucket.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(statusline): style "ruflo settings" as bold command, not a dead link

User feedback: "the area don't link to anything, manage: ruflo settings"
— clarified they expected "manage: ruflo settings" to be clickable/actionable.

It can't be a real link: a terminal has no safe way to execute a shell
command from a click — that would let any server-served promo message
run arbitrary commands on the user's machine. So instead of leaving it
looking like inert plain text (which reads as broken/half-finished), it
now gets its own visual treatment:

  ✨ <label, underlined + OSC 8 link>  ·  manage: <ruflo settings, BOLD>

- "manage: " stays dim (a connector word, no action implied)
- "ruflo settings" renders BOLD, deliberately WITHOUT underline or OSC 8 —
  bold signals "this is the important bit, copy/type it" without the
  false affordance of "click me" that underline would carry

Split changed from `text.indexOf(' · manage')` to the more precise
`text.indexOf(' · manage: ')` so the exact command substring can be
isolated for its own styling. getPromoRow() now returns three concatenated
segments instead of two: label(+OSC8), dim connector, bold command.

Test updated to assert: label sandwiched in UL_ON/UL_OFF + OSC8, connector
in DIM_ON/DIM_OFF, command in BOLD_ON/BOLD_OFF, and — critically — that
the command is NEVER wrapped in underline or safeTerminalLink (regression
guard against accidentally making a "command" look clickable again).

Verified via hex dump of the live render: ESC[1m immediately before
"ruflo settings", ESC[22m immediately after, no ESC[4m or OSC 8 escape
anywhere near it.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(funnel): use raw Cloud Run URL until funnel.ruv.io cert lands

funnel.ruv.io's TLS cert has not yet provisioned (Cloud Run domain
mapping created 2026-07-10 18:33 UTC; Google's cert issuance is async
and still pending as of this commit — verified: curl to funnel.ruv.io
returns curl exit 35 / HTTP 000, raw Cloud Run URL returns 200).

Every client fetch to messages/events/click was silently timing out
(by design — fail-silent), so:
  - the remote 23-message pool never reached any installed CLI
  - message-transport's cache file never got created
  - rotation only ever showed the 3 static in-code disclosure variants

All three endpoint defaults now point at the raw Cloud Run URL:
  https://cognitum-analytics-63rzcdswba-uc.a.run.app/v1/{events,messages,click}

Marked TEMP in comments. Flip back to funnel.ruv.io once its cert is live
(monitor: curl -o /dev/null -w '%{http_code}' https://funnel.ruv.io/v1/messages).

## Live verification performed this commit

1. Forced message-pool refresh — 23/23 messages accepted, 0 rejected
2. Selected messages across 8 consecutive 20s rotation slots — 8 distinct
   ids returned (7 educational + 1 promotional), confirming rotation moves
3. Impressions queued locally (8/8), consent-gated
4. Flushed queue to live server — 8/8 accepted, queue drained to 0
5. Fired the click-redirect for the selected promotional message with a
   forged CF-IPCountry: CA header — 302 to agentics.org with UTM+fid
   intact
6. Firestore confirmed both events landed:
     promo_impression  messageId=promo-agentics-community
     promo_open        messageId=promo-agentics-community  country=CA
                        via=click-redirect
7. Total promo_impression docs in funnel_events: 9 (8 new + 1 prior)

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(funnel): zero local promo content — fully remote-served, fail-closed

Per explicit direction: no local promo content or URLs should ship in the
CLI at all. This completes the ADR-311 remote-message architecture:
disclosure text, educational tips, and promotional messages are now ALL
served exclusively from the Cloud Function / Firestore feed. If the feed
is unreachable and no prior fetch has succeeded, the promo row renders
nothing — there is no local fallback content to degrade to.

## What changed

- messages.ts: MESSAGES array emptied to []. Zero message text/URLs ship
  in the package. isValidMessage() extended to accept a new 'disclosure'
  class alongside educational/promotional, with an invariant that
  disclosure-class text must retain the exact " · manage: ruflo settings"
  tail (dropped, never repaired, if missing).
- types.ts: FunnelMessageClass extended to 'educational' | 'promotional'
  | 'disclosure'.
- disclosure.ts: removed the hardcoded DISCLOSURE_TEXTS/DISCLOSURE_TEXT/
  DISCLOSURE_SPONSOR_URL constants and selectDisclosureText(). Replaced
  with selectDisclosureMessage() — deterministic 5-min-slot selection over
  the remote pool filtered to class==='disclosure', returning null when
  that pool is empty (cold start / API down). The disclosure state MACHINE
  (never_seen / disclosed_enabled / disclosed_disabled tracking) stays
  100% local — that's app state, not promo content.
- promo.ts: getFunnelPromo() now sources the disclosure row from
  selectDisclosureMessage(); returns null (fail-closed) when unavailable.
  Also wires the previously-defined-but-never-fired 'disclosure_shown'
  event so disclosure impressions are now tracked alongside promo_impression.
- statusline-generator.ts: PROMO_LINK_HOSTS gains the click-redirect hosts
  (funnel.ruv.io + the raw Cloud Run hostname) — these are DIFFERENT from
  the final-destination hosts (cognitum.one/agentics.org); without this the
  renderer's independent host-allowlist check correctly refused to OSC-8-
  wrap click-tracked URLs, which was silently breaking every clickable
  link this session had just added.

## Server (cognitum-one/ruflo-funnel-api@698aa73 — deployed live)

- All 16 educational tips now carry url: https://cognitum.one and a
  trailing 🔗 glyph (checked to fit the 80-col bound, max observed 78/78).
- 3 disclosure-class messages seeded (disclosure-1/2/3), matching the
  copy this session iterated on, now server-authoritative instead of
  hardcoded.
- promo.ts routes EVERY message with a url (not just class==='promotional')
  through the server click-redirect, so click tracking is uniform across
  the whole rotation.
- Firestore funnel_messages collection: 26 total (16 educational + 7
  promotional + 3 disclosure), all verified live via GET /v1/messages.

## Verified live end-to-end

- GET /v1/messages returns 26 messages, 16/16 educational have url set.
- Rendered statusline shows a real educational tip (edu-doctor) with a
  working OSC 8 hyperlink to the click-redirect, underline, 🔗ical suffix.
- Click-redirect → 302 to cognitum.one with UTM + fid intact; Firestore
  captured both promo_impression and promo_open for the same messageId.
- Fail-closed proven via test: selectMessage()/selectDisclosureMessage()/
  getFunnelPromo() all return null when the remote cache is unseeded.

## Tests: 68 → 70 funnel (78 total incl. drift-guard), all green

- New: "ships ZERO local messages" pins MESSAGES === [].
- New: disclosure-class validation (manage tail required).
- New: 3 fail-closed tests (selectDisclosureMessage / selectMessage /
  getFunnelPromo all null on unseeded cache).
- Rewrote disclosure-variant + promo-orchestrator tests to seed a mock
  remote cache (matching message-transport.ts's on-disk shape) instead
  of asserting against removed local constants.

Co-Authored-By: RuFlo <ruv@ruv.net>

* docs(adr-312): usage-limit downtime prevention — researched, phased plan

Direct instruction was to review Claude Code's actual CLI source (not
just docs) to determine feasibility of detecting approaching/hit usage
limits and surfacing a Cognitum meta-LLM proxy CTA as a downtime-
prevention / session-extension mechanism.

## Research performed (grounded against @anthropic-ai/claude-code@2.1.107)

Inspected /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js directly.
Findings:

1. A real internal rate-limit state object exists (module var, referred to
   in the ADR by its minified name since it will rename on any version
   bump): { status: allowed|allowed_warning|rejected, resetsAt (epoch
   sec), rateLimitType: five_hour|seven_day|seven_day_opus|
   seven_day_sonnet|overage, utilization (0-1 float),
   unifiedRateLimitFallbackAvailable, isUsingOverage }.

2. It's sourced from REAL Anthropic API response headers
   (anthropic-ratelimit-unified-status/-reset/-fallback/-{type}-
   utilization etc.) — confirmed via string match in the bundle. The
   exact "You've used N% of your session limit · resets HH:MM" banner
   text is generated from this exact object by a UI-only formatter.

3. This state is NOT exposed through any current extension point —
   checked exhaustively, empirically:
   - statusLine stdin JSON: confirmed absent (only model/context_window/
     cost fields exist — verified against ruflo's own parser)
   - Hook events: 28 hook names exist, none confirmed to fire on
     rate-limit transitions
   - Debug logs: EMPIRICALLY checked an existing 11,424-line debug log
     at default level — zero occurrences of any rate-limit header
   - No CLI command / MCP resource surfaces it
   - The only consumer is an internal React hook wired to Claude Code's
     own TUI, unreachable from outside its process

## Decision: 3-phase plan

- Phase 0 (buildable now): manual/self-reported flag —
  `ruflo settings notices rate-limited` — reusing the exact
  credit-notifier.ts pattern from ADR-303/311. Honest that this is
  user-reported, not detected.
- Phase 1 (blocked on upstream): file a Claude Code feature request to
  expose the real signal via statusLine JSON or a new hook event.
  Explicitly does NOT recommend building against the current minified
  internal names — fragile, version-coupled, would silently break.
- Phase 2 (once Phase 1 lands): wire the real signal into rotation with
  a priority override analogous to ADR-303's credit-exhaustion surface;
  new closed-vocabulary events rate_limit_warning_shown /
  rate_limit_exhausted_shown; same consent-gated click-tracking as every
  other promo message.

No code changes in this commit — ADR only, per the explicit request.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(funnel): sponsored-downtime capacity via cognitum-one/meta-proxy (ADR-312/313)

ADR-312 documents grounded research into detecting an approaching/hit Claude
usage limit: direct inspection of the installed Claude Code CLI (v2.1.107)
found the rate-limit state is populated from response headers
(anthropic-ratelimit-unified-status/-reset/-fallback/-*-utilization) that are
NOT exposed to hooks, the statusline JSON, or the debug log today (confirmed
by grep — zero occurrences in an 11k-line debug log). Automatic detection is
blocked on Anthropic exposing this state; documents a 3-phase plan (manual
flag now, wait on upstream, auto-wire once available).

ADR-313 implements Phase 0: a manually-set rate-limit flag
(`ruflo settings notices rate-limited [--clear]`) that, once set, preempts
normal promo rotation with a sponsored-capacity CTA — same precedent as
ADR-303's credit-exhaustion override. Two states depending on consent:
an enable CTA, or a quiet "active" confirmation once
`ruflo proxy sponsor-enable --yes` has been run.

Client-side (this commit):
- funnel/rate-limit-notifier.ts — TTL'd flag read/write/clear, mirrors
  credit-notifier.ts's shape
- funnel/promo.ts — priority-override hook, reachable only after the
  disclosure invariant is satisfied (ADR-301 compliance preserved)
- commands/proxy.ts — sponsor-enable/disable/status/clear subcommands;
  writes both the consent receipt (source of truth) and a mirror flag into
  ~/.ruflo/proxy-config.toml, the file the Rust proxy binary reads
- funnel/types.ts, consent.ts, events.ts — new 'sponsored-downtime' consent
  domain and sponsor_mode_enabled/disabled/capacity_exhausted events
- 10 new tests (rate-limit notifier + priority override), 80/80 passing

Server-side (separate private repo, already built/tested/pushed):
cognitum-one/meta-proxy — Rust/axum binary routing local/cloud/sponsored
data planes, per-user bearer token, loopback-only bind by default, never
falls back to sponsored capacity implicitly. Verified end-to-end: the Rust
binary correctly reads the same ~/.ruflo/proxy-config.toml this commit's
`sponsor-enable` command writes.

* docs(adr-313): addendum — proxy needed /v1/messages, not /v1/chat/completions

Verification found the shipped meta-proxy scaffold spoke the wrong wire
protocol for Claude Code (OpenAI chat-completions instead of the Anthropic
Messages API Claude Code actually POSTs to). Fixed in cognitum-one/meta-proxy
and verified live end-to-end with the real installed Claude Code CLI against
the live apicompletions Cloud Run service. Documents the fix and the
remaining gap (no automated sponsored-key provisioning yet).

* docs(adr-313): addendum — fourth Passthrough plane preserves subscription usage

Setting the env vars needed to reach the proxy at all previously meant giving up subscription-based auth entirely, all the time, not just when rate-limited. Documents the new default Passthrough plane (real Anthropic via the user's own OAuth token, read-only from Claude Code's own credentials file) and the live verification of all four planes switching correctly within one continuous session.

* fix(statusline): trailing blank line so the input prompt gets breathing room

The statusline's last row butted directly against Claude Code's input prompt with no visual separation. generateStatusline() now returns lines.join('\n') + '\n', so combined with console.log's own trailing newline the rendered output ends in exactly one blank line.

Regenerated both committed .claude/helpers/statusline.cjs copies (root + package) so the drift-guard test stays in lockstep. Two new tests: a source-level pin on the generator (can't silently regress) and a runtime check on the actual rendered bytes (exactly one blank line — not zero, not two).

* fix(helpers): re-sign manifest for the statusline trailing-newline fix

The earlier statusline fix (d3341b0) changed statusline.cjs's bytes without re-signing the ADR-174 manifest, so writeCriticalHelpers' fail-closed integrity gate correctly refused to propagate ANY critical helper to real installs (all-or-nothing verification) until this landed - confirmed via a live doctor run showing 'Skipped helper auto-refresh - integrity check failed for statusline.cjs'.

Re-signed via scripts/sign-helpers.mjs against the ruv-dev project's ruflo-helpers-signing-key secret. Also fixes verify-helpers.mjs's own CRITICAL list, which had drifted out of sync with sign-helpers.mjs and helper-refresh.ts:CRITICAL_HELPERS - it was missing statusline.cjs entirely, so the prepublishOnly gate would not have caught this drift before publish.

* feat(doctor): --component proxy — sponsored-downtime health check (ADR-313)

Reports whether the Meta LLM Proxy is installed (proxy-token present), sponsored-downtime consent state, the rate-limit flag, and the last locally-recorded sponsor_capacity_exhausted event (daily-bucket granularity, ADR-309). Warns when rate-limited but not opted into sponsored capacity, pointing at 'ruflo proxy sponsor-enable --yes'; warns (not fails) when the proxy isn't installed at all, since it's an optional feature.

Adds lastRecordedEvent() to funnel/events.ts - reads the local JSONL event queue for the most recent occurrence of a given event name. Returns null both when nothing happened and when telemetry consent is off (the queue is never written in that case either way) - same fail-closed posture as the rest of the funnel module.

* feat(hooks): rate-limit -> sponsored-capacity signup nudge (ADR-312/313)

When the manually-flagged rate-limit status (ADR-312 Phase 0) is active and sponsored-downtime consent hasn't been granted, the UserPromptSubmit hook now prints a nudge pointing at cognitum.one/meta-llm and 'ruflo proxy sponsor-enable --yes'. Fires client-side inside the route handler, BEFORE the API call that prompt would trigger - so it reaches the conversation transcript even if that call then fails from the rate limit, unlike the statusline CTA which only shows on the NEXT render.

Respects the same disable precedence as the rest of the funnel system (env RUFLO_FUNNEL=0, CI detection, user + project funnel.json/claude-flow.config.json disable) via cheap local file reads only - no network call, no child process, so it can't add latency to prompt submission. Wrapped in a top-level try/catch: a malformed state file must never break the hook.

Edited the real, hand-maintained .claude/helpers/hook-handler.cjs directly (root + package copies, kept byte-identical) rather than through generateHookHandler() in helpers-generator.ts, which is a fallback-only generator (per its own docstring) not the source of the shipped artifact - confirmed by regenerating from it and finding the output didn't match what's actually committed. Re-signed the ADR-174 manifest afterward, same as the statusline fix required.

* fix(funnel): flip endpoints to funnel.ruv.io (cert is live) + fallback generator parity

The cert-provisioning workaround from 34aa2ea2e was verified resolved and the endpoints were edited in this branch a while ago, but never actually committed - they'd been sitting as uncommitted working-tree changes since. Verified funnel.ruv.io serves all three routes correctly (200 on /v1/messages, 400 MISSING_IDEMPOTENCY_KEY on a bare /v1/events probe confirming the real handler, 302 on /v1/click) before flipping.

Also updates generateHookHandler()'s fallback template (helpers-generator.ts) with the same rate-limit signup nudge added to the real hand-maintained hook-handler.cjs, so the fallback path used when copying the real file fails stays behaviorally consistent.

* feat(funnel): ADR-314 power saver mode + sponsored-capacity abuse hardening (client side)

Adds the client-side pieces of ADR-314's design:

- power-saver-notifier.ts: self-reported quota-low flag (mirrors rate-limit-notifier.ts exactly - same manual/self-reported constraint, ADR-312's detection gap applies unchanged, ruflo cannot read your actual quota percentage). Deliberately separate from the rate-limited flag: running low (still have capacity) and blocked (zero left) are different urgencies.
- toggle-cooldown.ts: shared 10-minute cooldown on rate-limited/quota-low flag flips. A self-reported flag with zero server-side verification needs at least this much client-side friction against casual always-on gaming or a scripted flip-flop loop - it does not stop a determined abuser (that needs the per-user server-side quota this ADR flags as the real, currently-missing fix), but it raises the bar. markRateLimited/clearRateLimitStatus/markQuotaLow/clearQuotaLowStatus now return a boolean - false means the cooldown refused the flip, not that it silently applied.
- New 'power-saver' consent domain (ADR-302 pattern - never bundled with sponsored-downtime; power saver spends the user's OWN Cognitum credit, not free/sponsored capacity).
- ruflo settings notices quota-low [--clear], ruflo proxy power-saver-enable/disable/status/clear.
- New closed-vocabulary events: power_saver_enabled/disabled, toggle_cooldown_blocked.

13 new tests (cooldown edge cases both directions, power-saver notifier full lifecycle, TTL, notice text) plus one existing test updated to advance past the new cooldown window. 114/114 passing.

The Rust proxy side (Cloud-plane model rewrite to cognitum-auto for power saver, Sponsored-plane hard tier cap) ships in a follow-up commit to cognitum-one/meta-proxy.

* docs(adr): mirror ADR-315 (flywheel training from free users) from meta-proxy

Written primarily in cognitum-one/meta-proxy per request; mirrored here for the same reason ADR-313/314's addenda got copied the other direction -- both repos should have the full cross-referenced picture without depending on the other's branch/merge state.

* fix(statusline): promo row never loaded because the fetch that populates it was killed on every attempt

Root cause, confirmed live: the funnel message cache (~/.ruflo/funnel-messages-cache.json) fires its background refresh via 'void refreshRemoteMessages()' (rotation.ts) - fire-and-forget by design so the statusline's own per-render invocation never blocks on a network call. But the statusline is spawned as a short-lived subprocess per render (execSync). A fire-and-forget promise kicked off inside a process that's about to exit has no 'later' to run in - the HTTPS fetch gets killed before it can complete, so the cache never actually gets written. Reproduced directly: two consecutive cold-cache statusline renders, 5s apart, both returned promo:null and the cache file was never created.

Adds 'ruflo hooks refresh-funnel' - a small command that awaits refreshRemoteMessages() properly. Wires it into hook-handler.cjs's session-restore handler (which already runs once per session via the SessionStart hook chain) via a genuinely DETACHED + unref'd child process - decoupled from the hook's own process lifetime, so it keeps running (up to message-transport.ts's 4s fetch timeout) even after session-restore has already exited, and doesn't add to SessionStart's own timeout budget since it's never awaited there.

Verified live end-to-end: cold cache -> session-restore fires -> hook returns immediately (cache still absent) -> ~2-3s later the cache is populated (26 messages) -> a subsequent statusline render correctly shows a real promo row.

Not yet applied to generateHookHandler() (helpers-generator.ts's fallback-only generator, used when the real file copy fails) - lower priority, tracked as follow-up.

Also: re-signing the ADR-174 helpers manifest is blocked on gcloud reauth (session expired, needs interactive `gcloud auth login`) - the code fix itself is unaffected, but the signed-manifest propagation gate needs a re-sign once reauthed.

* feat(funnel): local insight ticker sharing the promo row

Adds a separate, environment/task-aware "co-pilot" ticker (insights.ts)
that shares the promo row's rendered slot but never touches the
remote-content pipeline — the promo row stays "zero local content,
fully remote-served, fail-closed" (ADR-301/311); insights are the
opposite by design (CVE counts, uncommitted changes, power-saver
state, an ADR-315 flywheel-status hook point), so they get their own
module and their own reserved rotation slot (slot%5==2, distinct from
rotation.ts's promo slot at slot%5==4). ADR-313's sponsored-downtime
override still preempts everything, insight slot included.

- insights.ts: computeLocalInsights()/selectLocalInsight(), priority-
  ordered candidates (CVEs pending > power-saver active > uncommitted
  changes > flywheel status), each reading only local/synchronous
  state (no network calls from here — that was the root cause of the
  earlier promo-cache bug).
- promo.ts: wires the insight slot into getFunnelPromo() after the
  ADR-313 override check.
- types.ts: PromoRow.kind gains 'insight', deliberately kept out of
  FunnelMessageClass (remote-content-only union).
- hooks.ts: passes git-uncommitted-count into the insight context.
- statusline-generator.ts: brightRed color for kind:'insight'.
- funnel.test.ts: 14 new tests covering per-insight thresholds,
  priority selection, and getFunnelPromo() integration on the
  reserved slot.

Regenerated + synced statusline.cjs artifacts (root + package copies).

* feat(funnel): ADR-315 Tier 2 client-side wiring — training-data-sharing consent

Implements the client-side piece ADR-315 explicitly scopes to ruflo/
meta-proxy: a new, never-bundled consent domain gating raw interaction
CONTENT (not just a routing decision) leaving the client.

- types.ts: 'training-data-sharing' ConsentDomain + training_share_
  enabled/disabled FunnelEventName, mirroring ADR-313/314's pattern.
- consent.ts / events.ts: registered in CONSENT_DOMAINS / EVENT_NAMES.
- proxy.ts: ruflo proxy training-share-enable/-disable/-status, its
  own disclosure text, and a training_share_consent_granted mirror
  field in proxy-config.toml (the file meta-proxy reads) — same
  read-modify-write helper already used for sponsored/power-saver.
- funnel.test.ts: 5 new tests confirming domain isolation (granting
  sponsored-downtime or power-saver never implies this one, and vice
  versa) and that the new event names pass the closed-set schema
  check.
- ADR-315: status updated to reflect what's actually shipped vs. what
  remains (the meta-proxy X-Cognitum-Training-Consent header relay,
  and meta-llm's own consent-gated capture — both still open).

No proxy behavior changes yet — meta-proxy's messages.rs still needs
the header-emission piece (tracked as the next step).

* test(hooks): add root/package parity guard for hook-handler.cjs

Investigated an apparent divergence between the two committed copies
of this critical helper (ADR-174) and traced it to the recurring
daemon-autostart corruption already documented earlier this session
(a background `daemon start` process reverts on-disk .claude/helpers/
files mid-session) -- the git-committed content on both copies was
already correct and identical; only the working-tree copy had been
silently reverted. Re-synced the live global (~/.claude/helpers) and
marketplace installs, which genuinely were stale (predating the
promo-cache fix and the ADR-312/313 nudge).

Adds a permanent test guarding against this exact class of divergence
recurring undetected in the future, whether from hand-edit drift or
tooling corruption: root and package hook-handler.cjs must stay
byte-identical. (Not compared against helpers-generator.ts's
generateHookHandler() -- that function is a deliberately simpler
inline fallback for when package-file-copy fails, not the source of
truth for the full-featured committed artifact.)

* fix(daemon): stop the recurring hook-handler.cjs/intelligence.cjs corruption

Root-caused the daemon-autostart file reversion seen repeatedly this
session (hook-handler.cjs/intelligence.cjs silently losing the
promo-cache fix + ADR-312/313 nudge, reverting to the simple
generator fallback template). Two real bugs compounded:

1. helper-refresh.ts's autoRefreshHelpersIfStale() refreshed on ANY
   version INEQUALITY, not just when the installed CLI is genuinely
   NEWER than the project's stamp. An older/stray installed binary
   (stale npx cache, marketplace install lagging an unpublished
   dev-tree fix) invoked via spawnDetachedFunnelRefresh() on every
   session-restore would see its own older version != this project's
   stamp and silently downgrade the helpers. Fixed: semver.gte gates
   the refresh to forward-only; equal or older is always a no-op.

2. getInstalledCliVersion()/findPackageHelpersDir() used a hardcoded
   '..','..','..' relative to __dirname, correct only when this
   module runs compiled three levels under the package root
   (dist/src/init/*.js). Running one level shallower (straight from
   src/init/*.ts — confirmed via vitest) silently mis-resolved BOTH
   functions with no error surfaced: version fell back to the
   placeholder '0.0.0', and the real package helpers dir was never
   found, routing into the "source unresolvable" fallback that
   regenerates from the simple generator templates -- unconditionally,
   no manifest, no version check. That regenerate-from-generator path
   is the literal content signature of every corruption observed this
   session. Fixed with findPackageRoot(): walk up from __dirname to
   the nearest ancestor whose package.json names '@claude-flow/cli',
   depth-independent.

3. Added a project-config opt-out for daemon autostart
   (claude-flow.config.json's daemon.autostart:false, gitignored,
   local-only) alongside the existing RUFLO_DAEMON_AUTOSTART env var.
   The env var alone has a real gap: ~/.bashrc's own
   `case $- in *i*) ;; *) return;; esac` guard skips the whole file
   for non-interactive shells, so an export in one such shell never
   reaches the next command's shell. A file on disk has no such gap.

Also rewrote helper-refresh.test.ts's successful-copy cases to build
an isolated, throwaway-keypair-signed fixture instead of depending on
this repo's own real (routinely-stale-mid-development) signed
manifest -- deterministic regardless of whether scripts/sign-helpers.mjs
has been re-run recently. 18 new/updated tests total across the two
files; 151 tests pass in the full regression run.

Local mitigation for the rest of this session: created (gitignored)
claude-flow.config.json with daemon.autostart:false at both the repo
root and v3/@claude-flow/cli, and synced the fixed dist into the
marketplace install (~/.claude/plugins/marketplaces/ruflo) so the
detached funnel-refresh subprocess spawned by hook-handler.cjs's
session-restore handler — confirmed via empirical stress test as the
actual trigger — runs the fixed code too.

* feat(funnel): ADR-316 -- Fable co-pilot advisor tip in the insight ticker

Wires the existing ADR-172 Fable Advisor Harness into the statusline's
local insight ticker, grounded against the real corpus first (no
"ruflo copilot advisor" existed pre-session; the closest real primitive
is services/fable-harness.ts, previously only consumed by the memory-
distillation oracle). This is the missing integration, built to respect
both subsystems' existing invariants:

- fable-harness.ts: new adviseCoPilotTip(snapshot) entry point, same
  cost/cwd/parsing discipline as judgeBatch/reflectFailures (fresh
  temp cwd, --append-system-prompt, --max-budget-usd, stdin-piped
  payload). Off by default -- spends nothing without a budget cap.
- funnel/advisor-tip.ts (new): three hard gates before any spend --
  consent ('advisor-tips' domain, never bundled), a 24h TTL checked
  BEFORE constructing the harness, and a $0.40 default budget cap
  (single-item calls use the ~$0.34 clean-cwd anchor, not the ~$0.02
  batched anchor). A "nothing worth saying" answer still stamps the
  cache so the same question isn't re-asked (and re-billed) within
  the window.
- funnel/local-signals.ts (new): getSecurityStatus/getSwarmStatus/
  getGitUncommittedCount extracted from commands/hooks.ts's
  statuslineCommand closure into a shared module -- pure refactor, so
  the statusline and the new refresh-advisor command can never
  silently drift on what these signals mean.
- commands/advisor.ts (new): ruflo advisor enable/disable/status.
  Client-side only, no proxy-config.toml mirror (unlike proxy.ts's
  sponsored/power-saver/training-share domains) -- this feature calls
  `claude -p` directly, never through the meta-proxy binary.
- commands/hooks.ts: refresh-advisor subcommand mirrors refresh-funnel
  exactly -- a properly-awaited CLI command meant to be spawned
  DETACHED from hook-handler.cjs's session-restore handler, so a real
  claude -p call gets a chance to finish without ever blocking the
  hook. Safe to invoke on every session-restore: the consent+TTL
  checks make an unconsented or fresh install a cheap no-op file read.
- insights.ts: advisorTipInsight(), priority 45 (between uncommitted-
  changes and flywheel-status), re-checks consent on every read so
  disabling stops the ticker immediately without needing to delete
  the cache file.
- types.ts/consent.ts/events.ts: 'advisor-tips' consent domain +
  advisor_tip_enabled/disabled events, following the ADR-313/314/315
  never-bundled pattern exactly.

Bug found and fixed along the way: both the new advisor-tip cache
reader and the pre-existing flywheelInsight() used `!cache._ts` to
check for a missing timestamp -- since 0 is falsy in JS, a legitimate
epoch-zero timestamp was indistinguishable from "no cache at all".
Surfaced by a deterministic-clock test; fixed in both places with an
explicit `typeof cache._ts !== 'number'` check.

29 new tests (fable-harness adviseCoPilotTip, advisor-tip consent/TTL/
cache-shape, funnel.test.ts consent-domain isolation + insight-ticker
priority ordering) plus the local-signals extraction verified as a
pure refactor. Full CLI surface smoke-tested end-to-end in an isolated
state dir (enable/status/disable, --help output) without ever
triggering a real `claude -p` spend. Regenerated dist, synced
hook-handler.cjs + dist to the marketplace install and all local npx
caches so the live session picks up the fix immediately.

* fix(statusline): promo row not rotating every 20s -- cache TTL mismatch

Bug report: "the promo area doesn't seem to be rotating every 20
seconds." Root-caused: getStatuslineData()'s general CACHE_TTL_MS
(60s -- #2337's fix for excessive CLI re-invocation during rapid
Claude Code re-renders) let cache.fresh stay true for up to three
whole 20s rotation slots. Since the rotation slot is ONLY ever
recomputed server-side inside the CLI subprocess this script shells
out to, a still-fresh 60s cache silently made the entire 20s rotation
design unreachable -- the row could only actually change once every
60 seconds at best, and sometimes not even then (same slot recomputed
on the next call).

Fix: track promo freshness on its own tighter clock
(PROMO_ROTATION_SLOT_MS = 20000, mirroring funnel/rotation.ts's
ROTATION_SLOT_MS) alongside the existing general TTL. The early-return
that skips the CLI call now requires BOTH `cache.fresh` and the new
`cache.promoFresh` -- so a still-fresh-by-general-TTL cache no longer
silently freezes the promo/insight row once its own 20s window has
elapsed. Does not touch or regress #2337's fix: the general 60s TTL
is unchanged, and the new check only forces a real CLI call more
often, never less.

New test pins the fix in the generated script (PROMO_ROTATION_SLOT_MS
constant, the promoFresh field, and the combined guard condition) so
a future edit can't silently drop it. 121/121 funnel tests pass, full
statusline-cost-display + init-wizard-bugs + hook-handler-artifact-
parity suites green. Regenerated + synced the .claude/helpers/
statusline.cjs artifact (root + package copies).

Separately investigated (not fixed here, flagged to the user): the
marketplace-installed CLI binary
(~/.claude/plugins/marketplaces/ruflo) is currently non-functional --
its entire node_modules tree is missing (not just dist/), so its
`hooks statusline --json` call always throws and the statusline
silently falls back to a local-only stub with no promo/rotation logic
at all. This is a separate, pre-existing infrastructure problem
(requires an actual package-manager install, not a file sync) that
compounds the symptom above but is out of scope for this fix.

* fix(statusline): fall through to a working CLI bin when the first candidate is broken

resolveCliBin() picked the first EXISTING candidate path and never tried
another, but "exists" isn't "works" -- this session's marketplace-installed
CLI (~/.claude/plugins/marketplaces/ruflo/bin/cli.js) exists on disk but
throws ERR_MODULE_NOT_FOUND for '@claude-flow/cli-core' on every invocation
(a stale dist/ missing a bundled workspace dep). Since that path always wins
the existence check, every render's CLI call failed, the promo memo could
never refresh, and once the memo aged past its 6h TTL the promo/disclosure
row disappeared entirely with no way to recover short of manual intervention.

resolveCliBinCandidates() now returns every existing candidate (not just the
first), and getStatuslineData() walks the whole list -- plus the npx
fallback -- trying the next one on any failure. One broken install can no
longer permanently wedge the funnel row.

Co-Authored-By: RuFlo <ruv@ruv.net>

* test(funnel): live end-to-end verification against the real GCP funnel service

ADR-308/311 wired the client to funnel.ruv.io (Cloud Function -> Firestore,
cognitum-20260110), but nothing -- client or server -- ever actually
verified the full path (POST /v1/events, GET /v1/click/{id}, allowlist
enforcement, Firestore write) against the live deployment. Every existing
funnel test is deliberately network-free.

Adds an opt-in (RUFLO_FUNNEL_LIVE_E2E=1), network-gated test file that:
- POSTs a real promo_impression batch to https://funnel.ruv.io/v1/events
  and confirms 200/accepted
- Hits the real click redirect and confirms a 302 to the allowlisted target
- Confirms a non-allowlisted host is refused, not redirected
- Queries Firestore directly (via gcloud ADC) to confirm both events
  actually landed in funnel_events, not just that the HTTP call succeeded
- Cleans up every doc it wrote in afterAll (best-effort, never fails the run)

Manually verified this exact flow prior to writing the test: real POST +
real click both landed in funnel_events, and the click also populated a
funnel_aggregates rollup row -- confirming the analytics plane genuinely
works end to end, not just that the client is wired to send to it.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(security): CVE-1 dependency pins + wire security scan result persistence

Two parts of the v3 security overhaul's CVE-1 (vulnerable dependencies)
category, done via the repo's own override mechanism rather than
`npm audit fix` (which is wrong for this pnpm monorepo and whose
opentelemetry "fix" is a destructive downgrade to agentdb@1.6.1 off the
3.x-alpha line).

- Dependency pins (source-level overrides, following the existing pattern):
  - root package.json: add missing js-yaml (>=4.3.0) — GHSA-h67p-54hq-rp68
    quadratic-DoS; root already pinned express/qs/opentelemetry.
  - v3/package.json pnpm.overrides: add js-yaml, express (>=4.22.2, qs DoS
    via body-parser), qs (>=6.15.2), and the @opentelemetry core/resources/
    sdk-trace-base/sdk-node set (>=2.8.0 / >=0.220.0) mirroring root's proven
    values — resolves the otel chain WITHOUT the agentdb downgrade.
  - Lockfiles are intentionally not regenerated here: the installed pnpm (11)
    would rewrite v3's lockfileVersion 6.0 → 9.0; regen must run under the
    repo's declared pnpm@8.15.0.

- Scan-result persistence: `security scan` now writes its result to
  `<target>/.claude/security-scans/scan-<type>-<depth>.json`, the exact path
  funnel/local-signals.ts:getSecurityStatus() reads for the statusline's
  security indicator. Best-effort (never fails the scan); deterministic name
  so repeated runs overwrite rather than accumulate. Verified end-to-end:
  getSecurityStatus goes PENDING/0 → IN_PROGRESS/1 after a scan.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(security): CVE-1 dependency pins for v3/@claude-flow/cli + persistence test

Applies the overrides already declared in v3/package.json's pnpm.overrides
(commit 7cf3b1084) by installing under the repo's own declared pnpm@8.15.0
(corepack prepare pnpm@8.15.0 --activate) rather than the environment's
pnpm 11, which would have rewritten v3/pnpm-lock.yaml's lockfileVersion
6.0 -> 9.0 as an unplanned side effect of an unrelated security fix.

v3/pnpm-lock.yaml now resolves js-yaml@5.2.1, qs@6.15.3, express@5.2.1,
and the @opentelemetry/{core,resources,sdk-trace-base,sdk-node} chain at
their patched versions -- confirmed via direct lockfile/store inspection
(single resolved entry per package, no coexisting vulnerable version).

Also adds the same overrides to v3/@claude-flow/cli/package.json's own
npm-native `overrides` field, since that's the package `security scan`'s
`npm audit` actually checks when run against this target. A pre-existing,
unrelated bug prevents a clean `npm install`/`audit fix` in this specific
subpackage (a sibling workspace package's own package.json uses pnpm's
`workspace:*` protocol, which plain npm's arborist can't resolve) -- so the
committed package-lock.json in this directory could not be regenerated via
standard tooling; this is a pre-existing repo/tooling gap, not something
introduced here.

Adds __tests__/security-scan-persistence.test.ts (4 tests, black-box
against the real built CLI binary) verifying: a real `security scan` run
writes .claude/security-scans/scan-<type>-<depth>.json, getSecurityStatus()
picks it up (no longer stuck at PENDING/0), and repeated scans overwrite
the deterministic filename rather than accumulate.

Verified: `npm run build` clean, full CLI vitest suite shows the same
pre-existing failure set before and after (68 failures / 12 files, all
missing-native-binary / docker-compose-drift / memory-resolution issues
unrelated to js-yaml/qs/express/opentelemetry -- zero new regressions).

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(security): regenerate root package-lock.json to apply js-yaml override

The js-yaml >=4.3.0 override was declared in root package.json (commit
7cf3b1084) but never actually applied -- root's package-lock.json still
resolved js-yaml@4.1.1 (GHSA-h67p-54hq-rp68). `npm install --package-lock-only`
regenerates the lockfile only (no node_modules churn) and picks up the
override cleanly, unlike v3/@claude-flow/cli's own lockfile which hits an
unrelated workspace:* protocol issue.

Verified: fresh `npm audit --json` at repo root now shows 0 vulnerabilities
(was 1 moderate). Resolved js-yaml version confirmed at 5.2.1.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): resolve PR #2622's 7 failing CI checks

- Static regression guards: install js-yaml/tsx for the smoke scripts into
  an isolated prefix and copy into node_modules, instead of `npm install`
  at repo root — the CVE-1 fix's `overrides.js-yaml` entry makes npm refuse
  any direct top-level install of that package (EOVERRIDE), regardless of
  version specifier.
- Plugin/hook-command install-safety: register RUFLO_FUNNEL,
  RUFLO_STATUSLINE_HYPERLINKS(_TMUX), RUFLO_ADVISOR_MAX_BUDGET_USD, the
  three RUFLO_FUNNEL_*_ENDPOINT vars, and RUFLO_STATE_DIR as known
  escape-hatch env vars — all are read from generated hook code or
  background transport, with no CLI command to attach a flag to.
- Hook-command install-safety: the funnel/statusline redesign silently
  renamed the #1951 regression guard's pinned `RUFLO_VERSION` variable to
  `pkgVersion`; renamed it back and regenerated both statusline.cjs copies.
- metaharness-real-data: OutputFormatter.printWarning/Info/Debug/Trace wrote
  to stdout instead of stderr (unlike printError), so a helper-refresh
  integrity WARN corrupted a `--format json` command's captured stdout.
  Routed all four to stderr, matching printError's existing convention.
- metaharness score / smoke-without-metaharness (17h): doctor.ts's
  --component help text grew a "funnel, proxy" suffix after "metaharness",
  breaking the guard's literal `metaharness)` pin; reordered so metaharness
  is listed last again.

smoke-without-agentbbs remains failing but is pre-existing on main, unrelated
to this PR's diff — not addressed here.

* fix(security): bound flag-list quantifier to stop ReDoS in npx-hook regexes

CodeQL js/redos (high severity, alerts #221/#223/#224): the shared
`(?:--?\S+\s+)*` flag-list sub-pattern used by the #2450/#2448 stale-settings
migration (executor.ts) and its doctor.ts health-check mirror exhibits
exponential backtracking. Confirmed locally: a crafted command string with
36 repetitions of a dash-token already took ~2.8s to reject/accept, growing
exponentially with each additional repetition — a malicious settings.json
committed to a shared repo could hang `ruflo init`/`ruflo doctor` for any
victim who runs it.

Fix: bound the repetition to {0,10} (no real invocation carries more than a
handful of flags), which caps worst-case backtracking to a constant. Verified
adversarial inputs up to 200 repetitions now resolve in ~0ms, and all three
regexes still match their intended legitimate command forms.

* fix(security): stop inheriting stderr in helper/config signing scripts

CodeQL js/clear-text-logging (high, alerts #244/#245): both scripts fetch
the ed25519 signing private key via `gcloud secrets versions access` with
stdio stderr set to 'inherit', forwarding the subprocess's stderr straight
into whatever is capturing this process's output (CI logs, terminal) with
no filtering. Neither script currently echoes the key material itself, but
inheriting stderr unconditionally means any future gcloud error/verbose path
that includes secret-adjacent detail would leak uncontrolled.

Capture stderr instead ('pipe') and keep printing only our own fixed error
message on failure — same user-facing behavior, no raw subprocess output
ever reaches a log.

* feat(versioning): ANV Phase 1 -- catalog-manifest.json + advisory version suffix

Implements Phase 1 of Agent-Native Versioning (ANV) per
https://gist.github.com/ruvnet/0d858ad440a4439b4a2281a40c39b1a0 --
`ruflo version --explain` now reports installed semver plus an advisory
`+ad.<n>.g<sha>.cat<generation>[.hal<tier>]` suffix (legal semver build
metadata, ignored by npm range resolution -- `ruflo --version`/`-V` and
plain `ruflo version` stay bare semver, unaffected).

- scripts/generate-catalog-manifest.mjs computes real, git-tracked counts
  (agents, MCP tools, skills) rather than fabricating numbers; the catalog
  `generation` only bumps when those counts actually change. No benchmark
  tier is emitted without a real signed GAIA/HAL submission for that exact
  generation -- omitted rather than guessed.
- Wired into `prepublishOnly` so every publish regenerates the manifest.
- `--require-catalog-gte N` gives scripts a capability-gating exit code.
- 8 new tests (unit + black-box CLI), all passing; verified no regression
  in commands.test.ts/p1-commands.test.ts (2 pre-existing failures there
  are unrelated -- reproduced identically on the unmodified tree).

Also: reduced the funnel disclosure grace period from 72h to 24h per
request -- the row shows the disclosure/opt-out text for 24h after first
render before promotional content becomes eligible, unchanged otherwise.
All 121 funnel tests still pass (the constant is referenced symbolically
everywhere, not duplicated).

* chore(release): bump to 3.26.0 across cli/umbrella/wrapper; fix stale output.test.ts assertions

Version bump per this repo's own semver policy (new CLI command = MINOR):
@claude-flow/cli, claude-flow (umbrella), and ruflo (wrapper) all move
3.25.6 -> 3.26.0 together, matching ANV's own Phase 1 target version.
Also adds v3/@claude-flow/cli/catalog-manifest.json to the umbrella
package's `files` array (the umbrella bundles the CLI's raw dist output
rather than depending on the npm package, so it needs its own copy).

output.test.ts: 4 assertions (printWarning/Info/Debug/Trace) still checked
stdout capture, predating this session's stdout->stderr routing fix for
those methods -- updated to assert on errorCaptured (stderr) instead,
plus an explicit assertion that stdout stays empty. All 59 tests in the
file pass now; caught via a full-suite run before publishing, not left
for someone else to trip over.

Verified via a stash/baseline comparison that the ~70 remaining test
failures across the suite are pre-existing (missing optional native/wasm
dependencies in this sandbox: @ruvector/ruvllm-wasm, agenticow, etc. --
same class of gap as the sql.js one found earlier this session), not
regressions from this branch's work.

* fix(security): npm-canonical self-reference for the agentdb override

v3/@claude-flow/cli/package.json declared BOTH overrides.agentdb and
optionalDependencies.agentdb for the same package -- exactly the pattern
diagnosed earlier this session for the js-yaml smoke-deps EOVERRIDE
(different symptom, same root cause: npm refuses ANY operation, including
publish, on a package.json where an overrides entry and a direct/optional
dependency target the same package unless the override uses npm's `$`
self-reference syntax). Reproduced live: `npm publish` failed with
EOVERRIDE before this fix, succeeded cleanly (verified via --dry-run,
1037-file tarball) after.

"agentdb": "$agentdb" forces every transitive resolution of agentdb to
match this package's own direct optionalDependency version -- same
security-pin intent as the literal ">=3.0.0-alpha.17" it replaces, just
expressed the way npm requires when the package is also a direct/optional
dependency of the manifest declaring the override.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 12:11:33 -04:00
rUv cd8a9c0988 feat: memory distillation self-learning loop (ADR-174) + page-agent browser intent (ADR-175) (#2570)
* feat(memory): distillation service — mine memory_entries into intelligence substrate (ADR-174 M0+M1)

Root cause the goal-planner surfaced: the daemon's consolidate worker
(worker-daemon.ts:1443) is a STUB writing hardcoded zeros — 6000+ commits
recorded to memory_entries but never distilled. reasoning_patterns /
episodes / pattern_embeddings / causal_edges were all empty (hence 🧠 0%).

M0 (safety) + M1 (service): runDistillation() mines memory_entries into
episodes → reasoning_patterns (+pattern_embeddings) → causal_edges.
- $0: reuses structured-distill.ts extractor + the embeddings already on
  each row; no LLM, no re-embedding.
- Incremental: distill_state rowid cursor per namespace; never rescans.
- Non-destructive: only inserts into the empty target tables; never
  touches memory_entries. Per-batch transactions.
- Safe on the just-recovered DB: quick_check gate skips corrupt DBs;
  better-sqlite3 optional (silent no-op).
- Provenance (ADR-171): feedback outcomes = oracle:test-exec (promotable);
  everything else = proxy:structural (written, NEVER promoted).

Measured on a copy of the real 7,900-entry DB: 7,899 → 4,260 patterns
(182 promoted, 4,078 proxy), 4,259 embeddings, 4,258 edges; source
unchanged; dry-run wrote nothing; 2nd run processed 0. 6 tests green.

ADR-174 records the decision, root cause, param surface, self-opt plan,
alternatives, rollback. Follow-ups on branch: M2 CLI, M3 daemon (replace
the stub), M4 self-optimization, M5 platform default.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(memory): distill CLI, daemon auto-distill, self-optimization (ADR-174 M2+M3+M4)

M2 — CLI: 'memory distill run|status|config' consuming the frozen M1
runDistillation. Full param surface: --dry-run, --namespace, --batch-size,
--dedup-distance, --max-entries, --since, --budget-usd, --judge (fable
gated on budget per ADR-172), --aggressive/--conservative presets,
--config, --db. status reports substrate counts + cursor + provenance
breakdown; degrades gracefully. 12 tests.

M3 — daemon: replaced the runConsolidateWorker STUB (the root cause — it
wrote hardcoded zeros) with a real runDistillation call every 30-min tick,
bounded to 1000 entries/tick (cursor drains a backlog across ticks, stays
well under the 16-min worker timeout). Real DistillReport written to the
metrics file. Opt-out via --no-distill / RUFLO_DAEMON_NO_DISTILL and the
existing 'daemon enable -w consolidate --disable'. Never throws (corrupt/
skipped surfaced, not fatal). 7 tests. The loop is now self-sustaining.

M4 — self-optimization ('ruflo tuning ruflo'): tuneDistillation grid-
searches {batchSize, dedupDistance, promoteThreshold} on ISOLATED copies,
scored by MRR@10/recall@10 on a rowid-based held-out split (train/held-out
disjointness asserted; held-out scored once). Source checksum unchanged.
Honest measured result on the real 7.9k-entry corpus: winner batchSize=200
dedupDistance=0.2 promoteThreshold=execution+corroborated, held-out MRR@10
0.753 vs 0.749 baseline — roughly on par (the substrate/training value is
the payoff, not raw retrieval uplift; measured-not-marketing). 11 tests.

Combined M1+M2+M3+M4: 30 tests green.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(browser): page-agent natural-language intent layer — browser_act (ADR-175)

Adds an in-page NL intent tool on top of the selector-based browser tools:
browser_act injects page-agent (Alibaba, MIT) into the agent-browser-driven
page and runs 'execute(task)' — one natural-language call instead of manual
selector orchestration.

Verified against the real page-agent@1.11.0 (ESM-only, split @page-agent/*
packages) — three load-bearing deviations handled:
- import('page-agent') throws 'window is not defined' in Node even when
  installed → availability probed via require.resolve (path only, no eval),
  like browser-session-tools.ts.
- the injectable IIFE auto-constructs a second PageAgent pointed at Alibaba's
  PUBLIC test endpoint (data leak on every call) → stripped before injection
  (stripDemoAutoInit).
- execute() returns { success, data, history } → mapped to { result, steps }.

Safety + routing:
- Optional dependency (~1.11.0) + v3 pnpm-lock regenerated same change;
  browser_act returns { degraded:true } (never throws) when absent.
- LLM routed through this CLI's Tier-3 ladder (OPENROUTER/OLLAMA, OpenAI-
  compatible); bare ANTHROPIC_API_KEY degrades (wrong API shape).
- KEY SAFETY: page-agent runs in page context, so the real key never enters
  the injected string — startLocalLLMProxy holds it server-side on a loopback
  http server and injects Authorization itself; page sees 127.0.0.1 + a
  placeholder. Tests assert no key substring in the injection.
- Trajectory → memory namespace 'browser' (feeds ADR-174 distillation).
- AIDefence gates returned content (optional; passes through if absent).

ADR-175 records the decision, the outer-harness/in-page split, constraints,
the 6 follow-on use cases, alternatives, and rollback. 16 tool tests +
145 mcp-client regression tests green; full build clean.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(browser): fail-closed demo-endpoint firewall for page-agent injection

The demo auto-connect (page content → Alibaba's public sandbox at
page-ag-testing-*.fcapp.run) was only neutralized by a best-effort text
strip that fell OPEN — stripDemoAutoInit returned the source unchanged if
the DEMO_MODEL marker moved, silently re-enabling the leak on an upstream
bundle change.

Verified via the real tarball: the demo endpoint lives ONLY in
dist/iife/page-agent.demo.js (6 refs); the library core ESM entry is clean
(0 refs). So no fork is needed — the leak is a property of the demo bundle,
not the library.

Fix: findDemoLeak() firewall + DemoLeakError. buildPageAgentInjection now
scans the stripped bundle for known demo-endpoint signatures
(page-ag-testing, .fcapp.run, DEMO_MODEL) and THROWS if any survive; the
browser_act handler catches it and degrades rather than injecting. This is
version-independent and fail-CLOSED — a moved/renamed marker can no longer
leak, because the guarantee is 'endpoint absent', not 'marker found'.

+4 tests (signature detection, clean-bundle pass, moved-marker refusal,
clean-bundle injection). 20/20 green. ADR-175 updated with the fork-vs-fix
rationale.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(browser): add ADR-112 discoverability guidance to browser_act description

CI failed 4 checks (tool-description audit + 3 smoke contracts that run it):
browser_act was the 1 tool of 361 without a 'Use when …' guidance signal.
The audit regex captures only the FIRST quoted segment of a multi-line
description, so the guidance phrase must lead. Reworded to open with
'Use when …' + 'Prefer … over …' + 'pair with …' + 'Falls back …'.
Audit now 361/361, exit 0.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(memory): M5 — promote M4 winning distill config as daemon default (ADR-174)

The daemon's consolidate worker now runs the M4-tuned config
(dedupDistance=0.2, chosen by the self-optimization grid-search on the real
corpus). Honest note in code + ADR: held-out MRR@10 0.753 vs 0.749 baseline
— on-par, the substrate/training value is the payoff. Overridable per-run
via 'memory distill'. Completes ADR-174 M0-M5.

Co-Authored-By: RuFlo <ruv@ruv.net>

* docs+fix(memory): address ADR-174 review — honest default, edge contract, invariants (ADR-174)

Review fixes:
- STATUS: Proposed → Accepted (M0-M5 implemented+tested, pending #2570 merge).
- TUNED DEFAULT honestly justified: the M4 retrieval delta (0.753 vs 0.749)
  is a TIE, so dedupDistance=0.2 is sold as COMPRESSION, not retrieval lift —
  measured 2,723 vs 4,350 patterns (37% fewer) at 11.6ms/batch, 0 violations.
  Added an explicit promotion rule (retrieval-neutral within 0.002 AND >=15%
  compression AND <250ms/batch AND 0 proxy violations).
- PARAM TABLE / DEFAULT consistency: dedup default is 0.2 everywhere (service,
  CLI, daemon). Presets now bracket it: --aggressive 0.3 / --conservative 0.1.
- CAUSAL_EDGES OVERCLAIM: edges now carry an explicit contract
  (edge_type=cooccurrence, provenance_tier=proxy:structural, confidence=0.3,
  promoted=false) with the rule 'may rank retrieval, must NOT justify
  autonomous action'. Also fixed a latent bug: edges linked epId-1 (assumed
  consecutive rowids) → now the actual previous episode id.
- EMBEDDING 1:1 invariant enforced: a cluster with no parseable vector is
  skipped, so every pattern has exactly one embedding (was off-by-one on a
  malformed source vector).
- ADR adds operational invariants + audit SQL, business-value framing, and an
  acceptance test.

+2 invariant tests (embedding coverage, edge contract). 32 distill tests green.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(hooks): capture edit/task FAILURES, not just successes (ADR-174)

The DB analysis found 898 feedback records — 100% success, 0 failures —
because the post-edit/post-task hooks recorded a hardcoded success:true and
never inspected the tool outcome. With no negative examples the oracle tier
can't teach good-vs-bad, which caps what distillation can learn.

Fix: read Claude Code's PostToolUse tool_response and record the REAL outcome.
- isToolFailure(hookInput): conservative detector (positive error signal only)
  — tool_response.is_error / error / success:false, non-zero Bash exit code,
  or an error string. Exported + unit-tested (5 cases).
- Generated hook (helpers-generator.ts) + the shipped .claude/helpers/
  hook-handler.cjs both inline it: post-edit → recordEdit(file, !toolFailed),
  post-task → feedback(!toolFailed) (feedback() already boosts on success /
  decays on failure — it was just never called with false).
- intelligence.cjs recordEdit(file, success) persists success to
  pending-insights so consolidation/distillation gets the negative example.

The distillation JUDGE (memory-distillation.ts judgeFeedback) already maps
success:false → reward 0 / not-promoted, so the substrate is failure-ready;
this closes the upstream gap that never produced failures.

End-to-end: a failed-edit PostToolUse payload now logs '[LEARN] Edit FAILURE
recorded' through the actual shipped hook; a success stays '[OK] Edit recorded'.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(init): version-stamped auto-refresh of critical helpers — hook fixes reach all users, no re-init (ADR-174)

The propagation gap: Claude Code hooks run the PROJECT-LOCAL
.claude/helpers/*.cjs copies, not the npm package — so npx ruflo@latest
never updated them, and users didn't know to re-run init. Any hook fix
(e.g. the ADR-174 failure-capture change) reached only people who happened
to re-init.

Fix: stamp the critical helpers (hook-handler.cjs, intelligence.cjs,
auto-memory-hook.mjs) with the installed CLI version; on the next CLI
command, if a project's stamp is older than the installed version, silently
re-copy them. New lightweight src/init/helper-refresh.ts (fs/path/module
only at load; lazily imports the heavy generators only on the rare
source-unresolvable fallback). Wired AWAITED into index.ts startup so a fast
command can't exit before the copy lands — fast path is a stamp read +
string compare (sub-ms); the copy runs at most once per version bump. No-op
outside a ruflo project (never scaffolds files in an unrelated dir). Skipped
for init/update. executeUpgrade also stamps so an explicit upgrade is current.

Verified end-to-end: a stale-stamped project with an OLD (no-failure-capture)
hook silently upgrades to the toolFailed logic on a plain 'ruflo --help', and
a current project is a no-op (a marker file survives). +4 tests.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(security): Ed25519 signed provenance gate for the helper auto-refresh (ADR-174)

The auto-refresh copies auto-EXECUTING hook code from the installed package
into projects. npm verifies the tarball at install, not the files on disk
after — a sibling package's postinstall (or disk tamper) could overwrite the
helpers and the refresh would faithfully propagate the malicious code.

Fix (fail-closed): every helper is verified against a ruflo-signed manifest
before install.
- helper-signing.ts: baked RUFLO_HELPERS_PUBKEY + verifyHelpersManifest()
  (native Ed25519, same primitive as rvfa-signing.ts). Fail-closed — any
  bad signature / tampered hash / wrong algorithm → null → refuse.
- scripts/sign-helpers.mjs: publish-time signer; private key from
  $RUFLO_HELPERS_SIGNING_KEY, NEVER committed (kept out of the repo tree).
  Not shipped (scripts/ is narrowed to postinstall.cjs in files).
- helper-refresh.writeCriticalHelpers: verifies the signed manifest + every
  source helper's SHA-256 BEFORE copying anything; a mismatch is refused and
  the project's existing helpers are left intact. The generator fallback
  needs no manifest (it's the CLI's own compiled code = trust root).
- index.ts warns loudly on a blocked refresh (potential tampering signal).

Threat model: closes post-install/on-disk tampering of the helper files; does
not defend a wholesale-compromised CLI (which could swap the baked key too —
but then it owns the binary you run). A CI test ('hashes match shipped files')
fails if a helper changes without re-signing, so a stale manifest can't ship.

Verified end-to-end: a tampered shipped helper → refresh REFUSED (blocked,
project helper untouched); restored → refresh succeeds. +6 signing tests.

Co-Authored-By: RuFlo <ruv@ruv.net>

* chore(security): source helper-signing key from GCP Secret Manager

sign-helpers.mjs now resolves the private key from GCP Secret Manager
(RUFLO_HELPERS_SIGNING_SECRET, via gcloud secrets versions access) as the
preferred source for CI/publish, with the local-PEM fallback
(RUFLO_HELPERS_SIGNING_KEY) for air-gapped signing. Key never committed.
ADR-174 security note updated.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-07-04 17:10:45 -04:00
rUv d759af6d6b feat(agenticow): COW memory substrate + governed distillation loop (ADR-170..173) (#2562)
* feat(agenticow): complete verb surface (ingest/query/diff/lineage/status) + nativeAnn + 0.2.4

Step 1 of the agenticow integration: the lifecycle verbs (branch/
checkpoint/rollback/promote) existed but the read/write verbs that make
a branch usable did not — you could create a COW branch via MCP but
couldn't populate or read it. Adds:
- agenticow_ingest — write {id?,vector,text?} records into a branch/base
- agenticow_query — k-NN across the full COW lineage (parent ∪ edits)
- agenticow_diff / _lineage / _status — introspection
- nativeAnn option on agenticow_branch (Rust dual-graph ANN fast path)
- targeted rollback via optional checkpointId

Floor bumped agenticow ~0.2.3 → ~0.2.4 (the upstream fix — text payloads
now survive the save/load round-trip our wrapper does on every call;
ruvnet/agenticow#3, published 0.2.4). 9 tools total; tests 10/10.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(swarm): per-agent COW memory branches via agenticow (agenticow step 2)

Wire the agenticow COW-memory primitive into the swarm agent lifecycle so a
swarm agent can own an isolated ~162-byte Copy-On-Write branch of a shared
.rvf base instead of a full copy — the structural fix for the v3.14.4
worktree-bloat (3.3 GB from full-copy per-agent snapshots).

SwarmMemoryBranches service (src/services/swarm-memory-branches.ts):
  - branchForAgent(base, agentId): fork a 162-byte COW branch (nativeAnn),
    idempotent, persisted to .claude-flow/swarm/cow-branches.json
  - promoteAgent(agentId): merge the agent's edits back into base, then delete
    the branch (call on success)
  - discardAgent(agentId): drop the branch, pure-fs, works even degraded
    (call on failure)
  - Non-fatal + CLAUDE_FLOW_NO_COW_MEMORY kill switch; agenticow lazy-loaded so
    it stays off the CLI startup path (--help unchanged at ~0.09s).

Seam + wiring (src/mcp-tools/agent-tools.ts):
  agent_spawn stores agents as pure JSON metadata today — it never forks/copies
  an .rvf, so there is no full-copy to replace inline. Wired in opt-in: pass
  `memoryBase` to agent_spawn to fork a per-agent branch (recorded on the
  AgentRecord); agent_terminate promotes (promoteMemory:true) or discards it.
  Default behavior is unchanged.

Shared loader (src/mcp-tools/agenticow-loader.ts): extracted loadAgenticow /
resolveMemoryPath / manifestFor / validateLabel / openWithLineage from
agenticow-tools.ts so the MCP verbs and the service share one optional-dep
dance (behavior-preserving; existing agenticow-tools tests unchanged).

Tests (__tests__/swarm-memory-branches.test.ts): kill-switch + degraded
no-op contract (always-on) and a real branch -> ingest -> promote/discard
lifecycle against a temp .rvf (skipIf agenticow absent), following the
agenticow-tools.test.ts conventions.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(agenticow): speculative branch-and-promote for A/B memory exploration (step 4)

Adds a self-contained SpeculativeExploration module + `agenticow_speculate`
MCP tool that composes on top of the existing agenticow COW verbs (fork /
promote). Fan out N candidate approaches, each on its own 162-byte COW branch
of a shared base .rvf; explore/score each independently; promote the winner's
branch into base and discard the losers by deleting their branch files. The
memory-state analogue of the worktree-per-agent pattern.

- src/agenticow/speculative-exploration.ts — generic `explore(base, {label,fn}[],
  score)` core + `exploreFromPath()` lifecycle wrapper. Returns
  {winner, scores, promoted, ...}.
- src/mcp-tools/_agenticow.ts — shared COW helpers (loadAgenticow / openWithLineage
  / manifestFor / resolveMemoryPath / validateLabel). ADDED alongside
  agenticow-tools.ts (kept byte-identical) to minimize merge conflict with a
  sibling branch.
- src/mcp-tools/agenticow-speculate-tools.ts — `agenticow_speculate` MCP tool
  (declarative candidates + probe-query / count scoring). Optional-dep graceful
  degradation per ADR-150.
- Registered in mcp-client.ts + mcp-tools/index.ts.
- __tests__/agenticow-speculate-tools.test.ts — 3-candidate deterministic-scorer
  test asserting winner promoted into base and loser branch files discarded
  (skipIf agenticow absent), following agenticow-tools.test.ts conventions.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(autopilot): checkpoint/rollback gate for loop ticks (agenticow step 3)

Add CheckpointGate — an agenticow-backed checkpoint/rollback bracket for
autopilot loops and long-horizon workflows. Before a risky tick that mutates
.rvf memory, take an O(1) agenticow checkpoint (162 bytes); if the tick
regresses (throws, or the caller's verdict says the outcome is worse), roll
memory back to the checkpoint — O(edits-since-checkpoint), not an O(N) rebuild.

- New src/services/checkpoint-gate.ts: CheckpointGate.guard(memPath, label, fn)
  plus low-level checkpoint()/rollback(). Lazy-loads agenticow (optional dep,
  ADR-150) — zero startup cost, graceful degradation to unguarded pass-through
  when the package is absent, the CLAUDE_FLOW_AGENTICOW_DISABLE kill-switch is
  set, or no memory path is configured. Non-fatal: an agenticow failure never
  masks the tick's own result.
- Opt-in wiring into the autopilot loop (commands/autopilot.ts autopilotCheck):
  checkpoint the configured .rvf right before a re-engaged tick; roll back on
  the loop's regression signal (stall auto-disable). Activates only when
  CLAUDE_FLOW_AUTOPILOT_CHECKPOINT_MEM points at an .rvf the loop mutates.
  The loop's memory mutation is out-of-process, so this brackets across ticks
  rather than wrapping an in-process fn; guard() is available for callers that
  do have an in-process fn.
- Unit test (__tests__/checkpoint-gate.test.ts): 10 cases — real temp .rvf
  checkpoint→mutate→rollback restore, success-keeps, throw-rollback-rethrow,
  and degraded/kill-switch pass-through. skipIf(agenticow absent), following
  agenticow-tools.test.ts conventions.

Co-Authored-By: RuFlo <ruv@ruv.net>

* docs(adr): ADR-170..173 — agenticow substrate, provenance oracle, Fable harness, remote distillation

Records the architectural decisions of the agenticow + distillation
governance integration:
- ADR-170: agenticow COW memory as agent-scoped workspace substrate
  (9 verbs, per-agent branches, speculative, checkpoint gate, one
  shared optional-dep loader; honest 'no full-copy to replace' scope)
- ADR-171: provenance-tiered evaluation oracle (execution → fable →
  proxy, each label tagged; promote-gated on real clearance; causal
  failure receipts; never proxy-as-gold — ADR-169 applied to labels)
- ADR-172: cost-disciplined Fable advisor harness via claude -p
  (clean-cwd + batching = $1.56→~$0.02/item; judge + GEPA reflector)
- ADR-173: remote GPU distillation via weight-eft over SSH
  (dry-run default, --execute --yes gate, parameterized host, hard
  honesty rule — no false 'trains a model' claim)

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(neural): tiered `resolved` oracle for distill/weight-EFT SFT data

Replace the single structural-confidence proxy for the per-trajectory
`resolved` boolean with a 3-tier labeler where every label carries honest
provenance (ADR-169 — never present a proxy as ground truth).

- TIER 1 `oracle:test-exec` (distill-oracle.ts): real mechanical eval for
  trajectories with a SWE-bench FAIL_TO_PASS spec or a metaharness/darwin
  bench-suite case. Runs over SSH on a PARAMETERIZED remote (--remote / env
  RUFLO_DISTILL_REMOTE, never hard-coded). DRY-RUN by default: emits the
  ssh/darwin-bench/eval command plan + preflight and touches nothing; only
  execute:true runs the real eval, behind a wrapped non-fatal probe.
- TIER 2 `judge:fable` (fable-harness.ts): cost-disciplined headless Fable
  LLM-as-judge for trajectories with no mechanical spec. Runs `claude -p`
  from a FRESH EMPTY temp cwd (no CLAUDE.md — the 5x cost driver), carries
  the judge role via --append-system-prompt, BATCHES 20 items/call (~$0.02
  /item vs ~$1.56 from the project dir), and enforces --max-budget-usd.
  Off by default; second entry point reflectFailures() feeds GEPA/evolve.
- TIER 3 `proxy:structural`: existing output-verifier confidence, weakest,
  clearly labeled.

Interface consumed by distill:
  labelResolved(trajectories, opts) -> [{...traj, resolved, resolvedBy,
  resolvedConfidence?, resolvedReason?}]. Default (no opts) = dry-run oracle
  preflight + proxy fallback: ZERO spend, no SSH exec, no Fable call.
  Everything ADR-150 optional/graceful.

Tests mock `claude -p` (spawn) and the SSH exec — assert command
construction, batching, provenance tagging, tier fallback order, and the
zero-external-call default. One opt-in live smoke behind RUFLO_FABLE_LIVE=1.
37 passing, 1 skipped (live), $0.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(neural): weight-eft $0 export slice + opt-in run-transcript capture path

Ship the ADR-150 weight-eft slice: turn ruflo run history into AUDITED
training DATA + a cost-Pareto measurement + a GPU plan. This does NOT train a
model and does NOT reduce escalation — weight-eft's own `train` never spawns
and no GPU tune has run. `resolved` is an explicitly-marked PROXY (ruflo has
no SWE-bench gold oracle).

Capture path (the blocker):
- NEW src/ruvector/run-transcript-recorder.ts — opt-in (CLAUDE_FLOW_RUN_TRANSCRIPTS=1,
  off by default; PII/retention surface mirroring the router trajectory recorder)
  recorder that persists full run records {instance_id, model, tier, resolved,
  resolved_source, messages[], model_patch} to .swarm/run-transcripts.jsonl.
- Seam wired in src/mcp-tools/agent-execute-core.ts next to the existing
  trajectory-outcome hook — the one place a run's transcript + model + tier +
  resolved-proxy are all known. resolved_source='api-success' (weakest proxy),
  model_patch='' (single-shot execute produces no diff) — both stamped honestly.
- router-trajectory.ts documents the companion split + adds unifiedRecorderStatus().

Distill command + service:
- NEW src/services/weight-eft.ts — optional-dep wrapper (dynamic import, graceful
  {degraded:true}; local type mirrors, no static @metaharness import). Archive
  builder (records → DarwinTrajectory[]), runExport/runPlan/runEval, plus a
  spend-gated remote-GPU train path (buildRemoteTrainInvocation + runRemoteTrain:
  DRY-RUN default, real compute only behind --execute --yes; ssh/rsync/ruvllm;
  host parameterized via --remote / $RUFLO_DISTILL_REMOTE, never hard-coded).
- `neural distill export|plan|eval|train` in neural.ts.
- @metaharness/weight-eft ~0.1.1 added to optionalDependencies.

Tests (18 in __tests__/weft-export.test.ts): archive-builder mapping, export E2E
(real weight-eft → sft.jsonl/dpo.jsonl/report), degraded path (absent dep →
{degraded:true}), remote-train command construction + dry-run/refused/preflight
gates, and capture-module opt-in gating. tsc build clean; CLI startup unaffected.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3

* merge: consolidate step-2/step-4 loaders into agenticow-loader.ts

* merge: step 3 (checkpoint gate) + consolidate 3rd loader into agenticow-loader.ts

All agenticow consumers (verbs, swarm branches, speculative, checkpoint
gate) now import one canonical loader — the first-class compat layer.
checkpoint-gate's inline copies removed; delegates to shared loadAgenticow.

* merge: oracle + weft + wire ADR-171 promote-gate & causal receipts (#23)

explore() and agenticow_speculate now enforce the ADR-171 promotion gate:
- a winning branch is promote-INELIGIBLE unless cleared by oracle:test-exec
  or an explicitly-accepted judge:fable — proxy:structural can NEVER clear,
  even when it claims cleared:true (fail-closed)
- requireClearance:true fail-closes without a gate — score alone never
  graduates work into base
- every discarded loser + an ineligible/failed winner emits a causal
  receipt {label, score, diff, outcome, provenance, reason}
- result carries promotedBy + promotionDecision for auditability

Back-compat: no clearance gate → legacy score-only promotion, honestly
tagged 'unverified' (never masquerading as ground truth). Tests 9/9.

Co-Authored-By: RuFlo <ruv@ruv.net>

* security(distill): bare dry-run is fully offline — gate ssh preflight behind --preflight

Adversarial RC finding: the remote-train dry-run ran a read-only 'ssh
host true' reachability probe, contacting the host without --execute.
Now the bare dry-run contacts NOTHING (prints commands only); the
read-only reachability/GPU probes require an explicit --preflight (or
--execute). Closes the 'no implicit remote execution' gate.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): regenerate v3/pnpm-lock.yaml for agenticow ^0.2.4 + @metaharness/weight-eft

Lock drifted from cli/package.json — had agenticow ~0.2.4 (pkg says
^0.2.4) and was MISSING @metaharness/weight-eft entirely, failing
--frozen-lockfile → cascading 34 CI job failures (the #2540→#2552
lesson). Regenerated; frozen-lockfile now clean.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): ADR-112 guidance clause on the 5 new agenticow verb descriptions

ingest/query/diff/lineage/status said 'Use to/after/before'; the ADR-112
audit requires the literal 'Use when' guidance signal. Reworded; audit
360/360 pass.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): register CLAUDE_FLOW_RUN_TRANSCRIPTS* as escape-hatch env vars (ADR-125/130)

The ADR-173 run-transcript capture path adds opt-in, off-by-default
background-recorder env toggles (PII/retention surface, mirroring the
existing CLAUDE_FLOW_ROUTER_TRAJECTORY* recorder). They have no
user-facing command, so they're env-only by design — registered as
known escape hatches like their router-trajectory analog. Audit passes.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): tilde-pin @metaharness/weight-eft + agenticow (anti-caret guard 17z73)

The metaharness smoke's iter-110 anti-caret regression guard requires all
@metaharness/* optional deps to be tilde-pinned (~) for reproducibility
(ADR-150 review-round-1). My earlier npm-lock revert accidentally flipped
@metaharness/weight-eft (and agenticow) to caret (^). Restored to tilde;
lock regenerated, frozen-lockfile clean.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-07-04 09:17:01 -04:00
Ofer Shaal a553da3439 fix(daemon): self-terminating TTL + idle shutdown, global status --all, honest HNSW/init footguns (#2361)
Carry-forward of the still-open, fixable-in-ruflo findings from
pacphi/ruflo-machine-ref (token-leak + honesty investigations). Most of
that kit's findings already landed upstream (#2219 SQLite, #2222 route
persistence, #2239 encoder collapse in 3.10.11); this addresses what
remained.

Daemon token-leak (root cause of multi-day background token burn):
- worker-daemon: self-terminating lifecycle. New ttlMs (default 24h) +
  idleShutdownMs (opt-in) config, sourced constructor > config.json
  (daemon.ttlSecs/idleSecs) > env (RUFLO_DAEMON_TTL_SECS/IDLE_SECS) >
  default. A monitor (unref'd, 60s) grace-shuts-down via stop()+exit(0),
  mirroring the signal path so the foreground ref'd keep-alive can't leave
  a zombie. An explicit 0 disables (preserves run-until-stopped).
- daemon command: --ttl flag (forwarded through the background fork) and
  TTL shown in the status/start boxes.
- daemon status --all: global view across every workspace via the same
  ps/tasklist scan used by kill-stale, reading each workspace's
  daemon-state.json for age + TTL and flagging daemons past their TTL/12h.
  Leaked daemons in other workspaces were previously invisible.

Honest status / init footguns:
- neural status: getHNSWStatus() now reports real capability
  (@ruvector/core resolvable) vs in-process load, ending the false
  "HNSW Not loaded" when the package is installed.
- init: keep auth-gated cloud MCP servers (ruv-swarm, flow-nexus) opt-in
  even under --full via a new --cloud-mcp flag (committed .mcp.json +
  per-session token cost).
- claudemd-generator: emit `ruflo@latest mcp start` (matches the actual
  generated .mcp.json) instead of legacy `@claude-flow/cli@latest`, and
  note the daemon's token cost + TTL.

Tests: TTL/idle precedence + 0-disable + monitor arming; the
extractWorkspaceFromDaemonLine helper behind --all; HNSW status contract
updated to assert initialized-vs-available separation.

Credit: investigation, reproductions, and statistical eval by
@pacphi (https://github.com/pacphi/ruflo-machine-ref).
2026-06-12 08:55:12 -04:00
rUv 100ffeaa35 fix(daemon): CPU-proportional maxCpuLoad replaces hardcoded 2.0 (rebased from #1353) (#1369)
Rebased from #1353 by @luis-b-o. CPU-proportional daemon threshold + 40 tests + all review fixes.
2026-03-17 13:41:56 -04:00
rUv 47cf544c2b Checkpoint: File edits
Automatic checkpoint created by Claude Code
- Branch: v3
- Timestamp: 2026-01-07T23:39:08Z
- Changes: 1 file(s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:39:08 +00:00
rUv 3b5a3b3c44 Checkpoint: File edits
Automatic checkpoint created by Claude Code
- Branch: v3
- Timestamp: 2026-01-07T23:38:39Z
- Changes: 1 file(s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:38:39 +00:00
rUv aceab3141c Checkpoint: File edits
Automatic checkpoint created by Claude Code
- Branch: v3
- Timestamp: 2026-01-07T23:38:09Z
- Changes: 1 file(s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:38:09 +00:00
rUv e6d77808a7 Checkpoint: File edits
Automatic checkpoint created by Claude Code
- Branch: v3
- Timestamp: 2026-01-07T23:37:59Z
- Changes: 1 file(s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:37:59 +00:00
rUv 004a41ac22 Checkpoint: File edits
Automatic checkpoint created by Claude Code
- Branch: v3
- Timestamp: 2026-01-07T23:37:40Z
- Changes: 1 file(s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:37:40 +00:00
rUv 036b061ec5 Checkpoint: File edits
Automatic checkpoint created by Claude Code
- Branch: v3
- Timestamp: 2026-01-07T23:37:18Z
- Changes: 1 file(s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:37:18 +00:00
rUv 0f0191836a Checkpoint: File edits
Automatic checkpoint created by Claude Code
- Branch: v3
- Timestamp: 2026-01-07T23:36:39Z
- Changes: 1 file(s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:36:39 +00:00