mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
d0ac73d798
* 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 from34aa2ea2ewas 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 (commit7cf3b1084) 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 (commit7cf3b1084) 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>
105 lines
4.5 KiB
TypeScript
105 lines
4.5 KiB
TypeScript
/**
|
|
* `ruflo version` — ANV (Agent-Native Versioning) Phase 1.
|
|
* https://gist.github.com/ruvnet/0d858ad440a4439b4a2281a40c39b1a0
|
|
*
|
|
* Black-box against the real built CLI binary (same pattern as
|
|
* security-scan-persistence.test.ts) since the command's manifest
|
|
* resolution walks real package/dev-checkout paths — reconstructing that
|
|
* via mocks would test the mock, not the resolution logic.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { execFileSync } from 'child_process';
|
|
import { existsSync, readFileSync } from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { buildAdvisorySuffix } from '../src/commands/version.js';
|
|
|
|
const CLI_BIN = fileURLToPath(new URL('../bin/cli.js', import.meta.url));
|
|
const CLI_BUILT = existsSync(CLI_BIN);
|
|
const MANIFEST_PATH = fileURLToPath(new URL('../catalog-manifest.json', import.meta.url));
|
|
|
|
function cli(args: string[]): { out: string; code: number } {
|
|
try {
|
|
const out = execFileSync(process.execPath, [CLI_BIN, ...args], { encoding: 'utf-8', timeout: 15_000 });
|
|
return { out, code: 0 };
|
|
} catch (e) {
|
|
const err = e as { stdout?: string; status?: number };
|
|
return { out: err.stdout ?? '', code: err.status ?? 1 };
|
|
}
|
|
}
|
|
|
|
describe('buildAdvisorySuffix — pure function', () => {
|
|
it('omits the hal segment when no benchmark exists', () => {
|
|
const suffix = buildAdvisorySuffix({
|
|
schemaVersion: 1, generation: 5, generatedAt: '2026-01-01T00:00:00.000Z',
|
|
gitSha: 'abc12345', catalog: { agents: 1, tools: 2, skills: 3 }, benchmark: null,
|
|
});
|
|
expect(suffix).toBe('+ad.1.gabc12345.cat5');
|
|
});
|
|
|
|
it('appends the hal segment only when a real benchmark is present', () => {
|
|
const suffix = buildAdvisorySuffix({
|
|
schemaVersion: 1, generation: 5, generatedAt: '2026-01-01T00:00:00.000Z',
|
|
gitSha: 'abc12345', catalog: { agents: 1, tools: 2, skills: 3 },
|
|
benchmark: { tier: 74, verifiedAt: '2026-01-01T00:00:00.000Z' },
|
|
}, 2);
|
|
expect(suffix).toBe('+ad.2.gabc12345.cat5.hal74');
|
|
});
|
|
|
|
it('is legal semver build metadata — starts with a bare "+", npm-range-safe', () => {
|
|
const suffix = buildAdvisorySuffix({
|
|
schemaVersion: 1, generation: 1, generatedAt: '2026-01-01T00:00:00.000Z',
|
|
gitSha: 'deadbeef', catalog: { agents: 0, tools: 0, skills: 0 }, benchmark: null,
|
|
});
|
|
expect(suffix.startsWith('+')).toBe(true);
|
|
expect(suffix).not.toMatch(/[^a-z0-9.+-]/i);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!CLI_BUILT)('ruflo version — CLI black-box', () => {
|
|
it('bare "version" prints plain semver, no suffix', () => {
|
|
const { out, code } = cli(['version']);
|
|
expect(code).toBe(0);
|
|
expect(out.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
});
|
|
|
|
it('bare "--version" global flag is unaffected by ANV (still plain semver)', () => {
|
|
const { out, code } = cli(['--version']);
|
|
expect(code).toBe(0);
|
|
expect(out).toMatch(/v\d+\.\d+\.\d+/);
|
|
expect(out).not.toContain('+ad.');
|
|
});
|
|
|
|
it('"version --explain" renders the catalog breakdown when catalog-manifest.json exists', () => {
|
|
if (!existsSync(MANIFEST_PATH)) return; // dev checkout without a generated manifest — see next test
|
|
const { out, code } = cli(['version', '--explain']);
|
|
expect(code).toBe(0);
|
|
expect(out).toContain('Era: AD (Agent Descent)');
|
|
expect(out).toMatch(/Catalog:\s+generation \d+ \(agents: \d+ types, tools: \d+ MCP, skills: \d+\)/);
|
|
expect(out).toContain('+ad.1.g');
|
|
});
|
|
|
|
it('catalog-manifest.json counts are real numbers, never fabricated benchmark data', () => {
|
|
if (!existsSync(MANIFEST_PATH)) return;
|
|
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
|
|
expect(manifest.catalog.agents).toBeGreaterThan(0);
|
|
expect(manifest.catalog.tools).toBeGreaterThan(0);
|
|
expect(manifest.catalog.skills).toBeGreaterThan(0);
|
|
// No benchmark tier without a real signed submission for this generation.
|
|
if (manifest.benchmark !== null) {
|
|
expect(manifest.benchmark).toHaveProperty('tier');
|
|
expect(manifest.benchmark).toHaveProperty('verifiedAt');
|
|
}
|
|
});
|
|
|
|
it('"--require-catalog-gte" gates cleanly: pass at/under, fail over', () => {
|
|
if (!existsSync(MANIFEST_PATH)) return;
|
|
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
|
|
const under = cli(['version', '--require-catalog-gte', String(manifest.generation)]);
|
|
expect(under.code).toBe(0);
|
|
expect(under.out).toContain('OK');
|
|
|
|
const over = cli(['version', '--require-catalog-gte', String(manifest.generation + 1000)]);
|
|
expect(over.code).toBe(1);
|
|
});
|
|
});
|