Skip to content

Proj/analytics system tool mode#726

Open
bao-byterover wants to merge 158 commits into
mainfrom
proj/analytics-system-tool-mode
Open

Proj/analytics system tool mode#726
bao-byterover wants to merge 158 commits into
mainfrom
proj/analytics-system-tool-mode

Conversation

@bao-byterover
Copy link
Copy Markdown
Collaborator

Summary

  • Problem: ByteRover CLI had no first-party analytics pipeline. Daemon
    lifecycle, CLI invocations, MCP sessions, curate/query funnels,
    HITL review, settings, version-control, and onboarding signals were
    all invisible to product / on-call.
  • Why it matters: without a funnel for usage and a failure-mode signal
    for the daemon, we cannot prioritise the next round of polish,
    catch silent regressions in agent flows, or measure feature
    adoption per release.
  • What changed: shipped the full analytics-system project, M1 through
    M15.8. New brv analytics enable | disable | status commands, a
    WebUI Configuration > Analytics panel, durable JSONL queue, batched
    HTTP sender with exponential backoff and identity-transition
    flushes, a 47-event catalogue with strict Zod schemas, a privacy
    fixture that statically rejects forbidden field names, daemon-side
    lifecycle hooks for tasks / curate / query, and producer-side
    wiring for daemon_start / cli_invocation / mcp_session_start /
    mcp_session_ended / mcp_tool_called.
  • What did NOT change (scope boundary): no analytics emit code lands
    under src/agent/ (layering rule preserved). No producer for
    onboarding_auto_setup_started / onboarding_completed yet,
    pending an agent-team signal (spec at
    agent-tmp/m15-8-onboarding-signal-spec.md). No space_switched
    producer (waits on the snapshot-removal project). The cli_metadata
    outgoing helper currently covers only 6 of around 80 oclif
    commands; the daemon middleware emits for all that participate.

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Also touched: WebUI Configuration > Analytics panel + analytics API
(not in the template list).

Linked issues

  • Closes ENG-3001 (M15.8 deferred-events wiring)
  • Closes 38 prior sub-tickets across M1 through M15.7 of the
    analytics-system project. Representative anchors: ENG-2611 (analytics
    GlobalConfig field), ENG-2628 (analytics:track transport event),
    ENG-2686 (per-event schema catalogue), ENG-2649 (e2e harness),
    ENG-2772 (lifecycle-hook frontmatter harvest), ENG-2807 (M13.3
    cli_metadata sweep batches 1+2), ENG-2961 (M15.1 foundation),
    ENG-2967 (M15.4 HITL + settings + reset), ENG-2963 (M15.5 WebUI
    session lifecycle), ENG-2964 (M15.6 tool-mode analytics).
  • Related: agent-team follow-up for onboarding_auto_setup_started /
    onboarding_completed signal, spec at
    agent-tmp/m15-8-onboarding-signal-spec.md. Not filed yet.

Root cause (bug fixes only, otherwise write N/A)

  • Root cause: N/A. This is a feature project, not a bug fix.
  • Why this was not caught earlier: N/A.

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only (the e2e harness runs against
      dev-beta; manual smoke validates real-backend send + sent
      status in the JSONL queue)
  • Test file(s): 88 test files added or modified under
    test/unit/server/infra/analytics/,
    test/unit/shared/analytics/,
    test/unit/infra/process/,
    test/unit/infra/client/,
    test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts,
    test/e2e/analytics/dev-beta.e2e.ts, plus webui +
    oclif-command tests.
  • Key scenario(s) covered: JSONL queue durability with crash mid-write,
    bounded-queue drop-oldest semantics, HTTP sender 2xx + 5xx + 400
    • offline, exponential backoff with reachability counter,
      auth-transition force-flush, batched flush (30s tick + 20-event
      threshold + shutdown drain), wire-side per-event Zod validation,
      privacy fixture (no forbidden field name across any event),
      daemon lifecycle hook drains in-flight per-op processing
      before terminal emit, WebUI session start + end with reconnect
      orphan-end, MCP session start + end with handshake-timing race,
      mcp_tool_called for success + error + cancellation,
      cli_invocation middleware fires on cli_metadata-bearing requests
      and bails on malformed metadata, idempotent middleware attach.

User-visible changes

  • New commands: brv analytics enable, brv analytics disable,
    brv analytics status (with --format json for scripting).
  • New WebUI panel under Configuration > General > Analytics:
    enable / disable toggle with first-run disclosure dialogue,
    status line surfacing queue depth + last successful flush +
    backoff state.
  • First-run disclosure: clear opt-in prompt. Default is OFF; no data
    is collected unless the user accepts.
  • brv login and 5 other oclif commands now send cli_metadata so
    the daemon can emit cli_invocation per command. No prompt
    difference to the user.
  • No change to existing commands' output, exit codes, or behavior
    when analytics is disabled.

Evidence

  • Failing test before, passing after: see per-milestone commits;
    M15.5 added 75 ClientManager session tests; M15.6 added the
    lifecycle-wiring integration test; M15.8 added 31 new cases
    (client-manager-mcp-analytics + analytics-hook-mcp-tool-called +
    task-router-client-identity-snapshot + cli-invocation-middleware).
  • npm test on the merge head: 9726 passing, 16 pending, 0 failing (run during M15.8 verification).
  • git grep "from.*shared/analytics/emit" src/oclif src/agent src/server/infra/mcp returns empty (daemon-only instrumentation
    rule holds).
  • Privacy fixture: green. Every event schema in ALL_EVENT_SCHEMAS
    rejects every name on FORBIDDEN_FIELD_NAMES.

Checklist

  • Tests added or updated and passing (npm test): 9726 / 16
    pending / 0 failing as of the M15.8 verification step.
  • Lint passes (npm run lint): 0 errors, 279 warnings
    (warnings are pre-existing repo noise, none introduced).
  • Type check passes (npm run typecheck): root TS check passes;
    the WebUI sub-project check (tsc -p src/webui/tsconfig.json)
    reports 6 errors in 4 files about TopicViewerProps missing
    breadcrumb / index / related. These pre-date this branch
    (confirmed by git stash rerun on base) and originate from a
    separate adopt-callback-based-topic-viewer commit waiting on a
    @campfirein/byterover-packages bump. Flagged under Risks
    below; not a regression introduced by this PR.
  • Build succeeds (npm run build): not re-verified in this PR
    session because build:ui is gated on the same WebUI
    typecheck above. Verify after the topic-viewer package bump
    lands.
  • Commits follow Conventional Commits format: every commit on the
    branch uses feat: [ENG-XXXX] ... or fix: [ENG-XXXX] ....
  • Documentation updated (if applicable): CLAUDE.md was kept in
    sync as milestones landed (analytics commands, env vars,
    e2e instructions, settings keys, billing-line). Spot-check
    before merging.
  • No breaking changes (or clearly documented above): opt-in,
    default-off feature. No public command shape changed. The
    IAnalyticsClient interface is daemon-internal.
  • Branch is up to date with main: last merge from main was
    commit 0ef90884f Merge branch 'main' into proj/analytics-system-tool-mode. Verify zero diff vs.
    origin/main before pressing merge.

Risks and mitigations

  • Risk: WebUI typecheck errors on TopicViewerProps block the husky
    pre-push hook and any CI step that runs the full npm run typecheck. Six errors across context-detail-panel.tsx,
    root-index-detail.tsx, curate-tool-mode-sections.tsx,
    task-detail-sections.tsx.

    • Mitigation: not introduced by this PR. Resolves once
      @campfirein/byterover-packages is bumped to the version that
      exports the callback-based TopicViewer API (or build with
      BRV_UI_SOURCE=submodule after git submodule update --init --recursive). Coordinate with the shared-UI submodule owner
      before merging.
  • Risk: large surface area (253 files, around 26k LoC, 108 commits)
    raises review burden.

    • Mitigation: the work landed in 39 small per-milestone PRs into
      the project branch (each independently reviewed and merged).
      This PR is the integration-to-main step. Skim the commit list
      rather than the line-by-line diff.
  • Risk: analytics enabled in error could ship PII.

    • Mitigation: opt-in default-off, a runtime FORBIDDEN_FIELD_NAMES
      redactor with a fixture that fails CI if any event schema
      declares a name on the list, file paths are stripped to
      project-relative or sentinel'd, and the disclosure UX makes the
      opt-in explicit in both CLI and WebUI.
  • Risk: backend outage produces unbounded queue growth.

    • Mitigation: bounded queue with drop-oldest semantics, a JSONL
      cap (JsonlCapFullError), exponential backoff with a
      reachability counter, and brv analytics status surfaces the
      operational state for on-call.
  • Risk: agent processes acquire stale analytics state mid-task.

    • Mitigation: per-agent settings snapshot at fork time
      (agent-settings-snapshot.ts); per-task identity snapshot on
      TaskInfo (clientType + clientName) survives mid-task
      client disconnect.
  • Risk: M15.8 onboarding events stay empty until the agent team
    ships a skill:invoked / skill:completed signal.

    • Mitigation: gap is disclosed in this PR and in the agent-team
      spec at agent-tmp/m15-8-onboarding-signal-spec.md. Backend
      funnel queries should not yet reference these events.

Adds a typed analytics: boolean field to the GlobalConfig domain entity
with safe migration for legacy configs (defaults to false when absent
to preserve the opt-in promise across upgrades). Widens toJson() return
to a typed GlobalConfigJson shape. Type guard rejects non-boolean values
when the analytics key is structurally present.

Tests moved to test/unit/server/core/domain/entities/global-config.test.ts
and extended with the seven scenarios required by the ticket.
Adds the read-only brv analytics status oclif command, which prints
whether CLI analytics is enabled or disabled. Output is "Analytics:
enabled" or "Analytics: disabled" by default; --format json emits the
shape {"analytics": "enabled" | "disabled"} via the repo's standard
writeJsonResponse envelope.

Routing follows login / logout / locations: oclif uses withDaemonRetry
to emit a globalConfig:get transport event (auto-spawns the daemon).
The daemon-side GlobalConfigHandler re-reads the on-disk config every
call (no in-memory cache); on a fresh install it seeds a stable
deviceId via GlobalConfig.create(randomUUID()) so device identity is
available from the first read.

Help text describes what telemetry is collected and links to a
placeholder privacy policy URL pending M1.5.
Adds two oclif commands that toggle the analytics flag in GlobalConfig:
brv analytics enable and brv analytics disable. Output is "Analytics
enabled" / "Analytics disabled" on a state change, or "Analytics
already enabled" / "Analytics already disabled" when the flag was
already in the target state (idempotent, exits 0).

Routing matches M1.2: oclif uses withDaemonRetry to emit the new
globalConfig:setAnalytics transport event. The daemon-side
GlobalConfigHandler gains a SET_ANALYTICS listener that reads the
current config (or seeds a fresh one with a stable deviceId if absent),
applies the new value via GlobalConfig.fromJson round-trip (the entity
is immutable with a private constructor), and writes back. The
idempotent path skips the file write entirely when previous state
matches the requested value, so a disable on a fresh install does not
create a stub config file.

M1.3 ships without the disclosure prompt - the enable command flips
the bit immediately. M1.4 (ENG-2618) follows with the disclosure UX
wrapper. Reviewers should expect that follow-up.

Adds 13 new tests covering all 7 ticket Test plan scenarios:
4 deterministic command-level tests for enable/disable success and
idempotent paths, 1 transport-contract assertion per command, plus
3 handler-level integration tests for SET-then-GET state coherence
and concurrent-write last-writer-wins semantics.
Wraps M1.3's bit-flip path so brv analytics enable shows a disclosure
that the user must accept before the flag flips. CI / non-interactive
use is supported via --yes.

Flow: the command first reads current state via globalConfig:get. If
analytics is already enabled, it prints "Analytics already enabled"
and exits 0 with no prompt and no write. Otherwise it loads the
disclosure markdown, prints it, and either accepts implicitly (--yes),
prompts via @inquirer/prompts confirm (interactive TTY), or refuses
with a clear error (non-TTY without --yes). On accept, the existing
globalConfig:setAnalytics path flips the bit; on reject, "Analytics
not enabled" prints and the command exits 0 without writing.

Disclosure copy lives in src/server/templates/sections/analytics-disclosure.md
with lorem ipsum bodies per hoang's request in the Linear ticket.
Section headers (what / surfaces / where / cross-device alias / how to
disable / privacy policy) are load-bearing for tests and remain stable;
PM and legal will replace bodies before the M1 release. Privacy policy
URL lives in src/shared/constants/privacy.ts as a placeholder pending
the M1.5 docs page; reviewers should update it once the canonical URL
is finalized.

A TODO(M2) marker is preserved in enable.ts so that when IAnalyticsClient
lands the first event sent after enable will be analytics_enabled
itself (industry practice).

Adds 8 tests covering all 7 ticket scenarios: interactive accept,
interactive reject, --yes bypass, already-enabled short-circuit,
non-TTY refusal with non-zero exit, disclosure section coverage, and
privacy URL constant shape.

PM/legal sign-off and the milestone disclosure-file link are manual
steps to record at PR / milestone-update time.
Establishes the consumer-facing contract for the daemon-side analytics
machinery. Future M2 sub-tickets (queue, resolvers, real client,
transport handler, IPC bridge) build against this interface; the no-op
serves as the daemon's default before M2.5 wires the real one and as a
stub for unit tests.

Adds the domain types in src/server/core/domain/analytics/:
  - AnalyticsEvent (internal camelCase: name, properties, timestamp)
  - Identity (wire snake_case: user_id?, device_id, email?, name?)
  - AnalyticsEventWithIdentity (Identity-stamped event)
  - AnalyticsBatchJson (wire shape: schema_version: 1, events)
  - AnalyticsBatch class with private constructor + static create +
    static fromJson (graceful failure, returns undefined on malformed
    input) + toJson, mirroring the GlobalConfig pattern

Adds IAnalyticsClient { track; flush } in core/interfaces/analytics/
and NoOpAnalyticsClient in infra/analytics/. The no-op's track() is a
true no-op (no allocations beyond the call frame, no buffering); its
flush() always resolves to an empty batch.

Tests: 24 cases. AnalyticsBatch round-trips empty + populated batches,
and fromJson rejects 13 distinct malformations (missing schema_version,
schema_version != 1, events not array, missing/non-string event name,
missing identity, missing/empty device_id, non-number timestamp,
non-object properties, etc). NoOpAnalyticsClient verifies track does
not throw under varied input and flush stays empty after many tracks.
Adds the in-memory queue that M2.5's AnalyticsClient will buffer events
into. Configurable cap with default 1000, drop-oldest on overflow, and
a cumulative droppedCount that survives drain calls for later
observability (surfaced in M4 via brv analytics status).

The interface IAnalyticsQueue lives next to its sibling IAnalyticsClient
under core/interfaces/analytics/. The BoundedQueue implementation in
infra/analytics/ uses a plain Array with push + shift; at maxSize=1000
the O(n) shift on overflow is negligible. drain transfers ownership of
the events array to the caller and re-initializes the internal queue,
avoiding a defensive clone. dropped is a private counter that no
method resets.

The constructor validates maxSize is a non-negative integer; negative
NaN, Infinity, and fractional values throw fast at construction time.
Without this check, a negative maxSize would cause an infinite loop in
push(): the `while events.length > maxSize` condition stays true at
length === 0 when maxSize < 0, with shift() as a no-op.

Tests cover the six ticket scenarios plus constructor validation:
FIFO drain, empty-queue defaults, drop-oldest with droppedCount
tracking, multi-drop FIFO order, cumulative droppedCount across
drains, size() invariant, default-cap behavior, drain ownership
transfer, and 5 constructor cases (negative, NaN, Infinity, fractional
all throw; maxSize === 0 is accepted as a degenerate but valid cap).
17 tests total.

Test fixtures use a fresh identity object per makeEvent call rather
than a shared module-scope reference, so future tests cannot
accidentally mutate the shared identity and bleed across cases.

Note: had to introduce a small pushAll(queue, events[]) helper in the
test file because the unicorn/no-array-push-push lint rule fires on
consecutive .push() calls regardless of receiver type. The helper
wraps a for-of loop, which the rule accepts.
Adds the resolver that stamps the five super properties onto every
analytics event: device_id, cli_version, os, node_version, environment.
M2.5's AnalyticsClient will inject this resolver and merge its output
into every track() call.

Wire-format snake_case throughout (matches the rest of the analytics
domain). cli_version, os, node_version, and environment are static
across the daemon's lifetime and lazy-cached on first resolve(). The
package.json reader is invoked exactly once per resolver instance,
verified by a sinon-stub assertion.

device_id is re-read from IGlobalConfigStore on every resolve() call
so a swapped GlobalConfig is observable. The tradeoff: resolve()
returns Promise<SuperProperties> instead of sync SuperProperties,
which cascades async to M2.5's AnalyticsClient.track() (deviation
from M2.5's spec to be addressed when M2.5 lands).

The cli-version reader was duplicated inline in brv-server.ts; this
commit extracts it to src/server/utils/read-cli-version.ts and both
sites now import the single source. The walk-up depth changes from 4
to 3 levels (the new utility lives one level closer to the project
root) but resolves to the same package.json.

environment defaults to 'production' when BRV_ENV is unset or set to
any value other than 'development'. Fail-safe in unfamiliar territory.

10 tests cover the seven ticket scenarios plus three additional cases:
device_id re-read on every call, environment default when BRV_ENV is
unset, and BRV_ENV=production explicitly. Tests save & restore
process.env.BRV_ENV per scenario to avoid cross-test pollution.
Adds IdentityResolver — produces the per-event Identity that M2.5 will
stamp onto every analytics event. Anonymous: {device_id} only.
Registered: {user_id, email?, name?, device_id} where empty user fields
are OMITTED (not present as undefined) so the wire envelope stays
clean for downstream serializers.

Per ticket DoD, defines a consumer-side IAuthStateReader interface
(1 method: getToken()) co-located with IIdentityResolver in
core/interfaces/analytics/. The full IAuthStateStore is broader; the
resolver only needs sync access to the cached token. This keeps the
auth module unaware of the analytics consumer, matching CLAUDE.md
"interfaces at the consumer".

resolve() is async because IGlobalConfigStore.read() is async (same
precedent as M2.3 super-properties resolver). Each call re-reads both
sources so auth-state transitions mid-batch are observable to M2.5
(M2.5 Test #3 requires this).

Empty-field omission uses conditional spread:
  ...(token.userEmail ? {email: token.userEmail} : {})
which OMITS the key entirely. Tests assert via chai's
.to.not.have.property('email') to distinguish absent keys from
explicit-undefined values.

9 tests cover all 6 ticket scenarios plus 2 bonus cases (empty
userName, missing GlobalConfig). Auth-transition tests use a mutable
authReader wrapper to flip identity between resolve() calls,
verifying per-call freshness with no caching.
Composes M2.2 (queue), M2.3 (super-props), M2.4 (identity) into the
daemon-scoped AnalyticsClient. GlobalConfigHandler now caches the
analytics flag synchronously so AnalyticsClient.isEnabled stays sync;
refreshCache() is awaited in setupFeatureHandlers before construction
so the very first track() (daemon_start) sees the correct flag.

Adds an ESLint no-restricted-imports rule preventing IAnalyticsClient
from being imported outside src/server/infra/; non-daemon consumers
should reach analytics through the analytics:track transport event
(M2.6).
- refreshCache(): catch block now explicitly sets cachedAnalytics=false
  on read failure. Under the prior fix the field defaulted to undefined,
  so a silent catch left getCachedAnalytics() throwing — which would
  crash the daemon on bootstrap if any IGlobalConfigStore implementation
  ever rejected from read(). Production FileGlobalConfigStore catches
  its own errors, but defense-in-depth restores the comment's promise.

- GlobalConfigHandler class JSDoc: corrected stale "populated at
  construction" wording. The eager constructor-refresh was removed
  during the prior fix-pass; consumers now MUST await refreshCache().

- AnalyticsClient class JSDoc: tightened the no-crash invariant. The
  guarantee covers async resolver/queue errors only — a sync isEnabled()
  throw (e.g. getCachedAnalytics before refreshCache) propagates by
  design to surface bootstrap-misconfiguration loudly.

- New integration test locks the refreshCache fail-safe: a stub
  IGlobalConfigStore whose read() rejects must leave the cache at false
  (NOT undefined), and getCachedAnalytics() must NOT throw afterwards.
Implements M2.6 transport mechanism for analytics:

- analytics:track transport event with wire-level Zod validation
  (src/shared/transport/events/analytics-events.ts,
   AnalyticsTrackPayloadSchema in core/domain/transport/schemas.ts)
- Daemon-side AnalyticsHandler routes valid payloads to
  AnalyticsClient.track (src/server/infra/transport/handlers/
   analytics-handler.ts, wired in feature-handlers.ts)
- Shared emitAnalytics helper at src/shared/analytics/emit.ts for
  in-process consumers (TUI, oclif, MCP, webui)
- Round-trip integration test exercises stub-emit -> handler -> queue
  with full identity + super-properties stamping

Deviation from ticket spec (see ENG-2628 Linear comment):
- Oclif sample consumer (cli_invocation from lifecycle hook) NOT
  shipped -- team decided against oclif lifecycle hooks.
  Follow-up ticket needed.

Tests added: 21 (schema 9, handler 5, helper 4, integration 3).
Schema-only milestone. No emitter wiring, no daemon-side validation.
Each shipped analytics event now has a TypeScript type plus a Zod
.strict() runtime schema, and typed enums eliminate magic strings.

Events covered (7):
- daemon_start (M2.5): empty schema; super-properties cover cold-start
- cli_invocation (M2.6): command_id, flag_names, is_tty, is_ci,
  terminal_program?, runtime, package_manager
- mcp_session_start (M2.6): client_name
- mcp_tool_called (M2.6): tool_name, client_name, success, duration_ms
- task_created: task_type, task_id, has_files, has_folder
- task_completed: task_type, task_id, duration_ms
- task_failed: task_type, task_id, duration_ms

Typed enums:
- AnalyticsEventNames (7 entries): wire-format event names
- TaskTypes (5 entries): mirrors daemon TaskInfo.type union
- TASK_TYPE_VALUES tuple: single source of truth used by every task_*
  schema's z.enum(...) call

Privacy fixture walks ALL_EVENT_SCHEMAS and rejects any field name on
the forbidden PII list (cwd, argv, path, email, result, ...). Adding
a banned field name later fails the build.

Wire-shape source of truth: AnalyticsTrackPayloadSchema lives in
shared/transport/events/analytics-events.ts (alongside the
AnalyticsEvents constants). Both the daemon handler and the shared
emitAnalytics helper consume the same Zod-derived
AnalyticsTrackPayload type, so the wire shape can no longer drift
between two declarations. The previous server-side duplicate has
been removed.

Drift detection: a new compile-time + runtime test asserts the
shared TaskTypes enum stays in lock-step with the server-side
TaskTypeSchema. If a daemon contributor adds a new TaskInfo.type
value and forgets to mirror it, the build fails loudly instead of
emitting events that silently fail wire-side validation.

Magic-string cleanup: the only existing analytics emit site at
brv-server.ts now passes AnalyticsEventNames.DAEMON_START instead
of the raw string literal.

Deliberate deviations from ticket text:
1. command_id is z.string() not a typed enum: oclif manifest is the
   source of truth (~80 commands), a hardcoded mirror would rot.
2. Emitter wiring (oclif init, MCP oninitialized, MCP wrapper) and
   TaskAnalyticsHook are deferred to a follow-up ticket; this
   milestone ships schema declarations only.
3. task_failed has no error_class/error_code: that would require a
   breaking ITaskLifecycleHook.onTaskError signature change which
   belongs in its own ticket.

29 files changed, 0 files modified outside this milestone's scope.
Tests: 7500 passing (+74 new under shared/analytics/ + drift + payload
schema). Lint, typecheck, build all green.
… depth)

Four review-driven hardening fixes on top of the M2.8 catalog. Zero
behavior changes for existing wire/runtime callers; all changes are
type-tightening or test-quality improvements.

NF2 — Typed `emitAnalytics`. The shared emit boundary now takes a
generic `<E extends AnalyticsEventName>` and derives the properties
type from the M2.8 discriminated union. Magic-string typos
(`'daemon_starts'`) and wrong-shape payloads
(`tool_name` on `cli_invocation`) become compile errors instead of
runtime drops. The properties argument is optional only for events
whose schema has no required keys (e.g. `daemon_start`); other events
require a fully-shaped payload.

NF5 — Replace batch.ts hand-rolled type guards with Zod. The three
`as Record<string, unknown>` casts (which violated CLAUDE.md
"avoid `as Type` assertions") are gone, replaced by
IdentityWireSchema / AnalyticsEventWithIdentityWireSchema /
AnalyticsBatchJsonSchema. All 19 existing fromJson rejection tests
still pass — Zod preserves the same edge cases (empty/whitespace
device_id, missing schema_version, non-object properties, etc.) at
~half the line count.

I5 — Privacy fixture walker recurses into nested ZodObject,
ZodArray elements, and ZodOptional / ZodNullable wrappers. Three
new regression tests verify the walker catches forbidden names
(`email`, `password`, `token`, `api_key`) embedded inside nested,
arrayed, and optional/nullable shapes. Today's schemas are flat, but
the fixture now stays correct as future schemas grow.

I6 — Extend FORBIDDEN_FIELD_NAMES from 20 to 41 entries: adds
secrets (`access_token`, `auth_token`, `api_key`, `cookie`,
`credential`, `password`, `secret`, `session_id`, `session_token`,
`token`, `auth_header`), additional PII (`address`, `display_name`,
`first_name`, `last_name`, `phone`, `phone_number`, `ssn`,
`username`). The list now matches a realistic threat model for the
analytics pipeline.

NF7 — Already covered by existing test #6 in
test/commands/analytics/enable.test.ts (retracted; my round-4 review
hallucinated the gap because I read only the first 80 lines of the
file).

Tests: 7503 passing (+3 walker tests). Lint, typecheck, build all
green. No production wiring changed; the only existing emit site
(brv-server.ts) still calls `analyticsClient.track(...)` directly
and is unaffected.
- New stored-record.ts: StoredAnalyticsRecord extends AnalyticsEventWithIdentity
  with {id, status, attempts}; Readonly via z.infer wrapper for consistency
  with rest of analytics domain (Identity, AnalyticsEvent, batch types)
- Zod schema for read-from-disk validation; default strip mode (matches
  batch.ts precedent, forward-compat with future field additions)
- Export MAX_ATTEMPTS=3 constant for M9.2's updateStatus retry-cap policy
- toWireEvent helper strips local-only fields (id, status, attempts) for
  M4 backend ship path
- batch.ts and existing M2 wire types unchanged (boundary preserved)
- 23 unit tests: 1 const + 16 schema + 6 helper; full suite 7526 passing
- New IJsonlAnalyticsStore interface: append/updateStatus/list/loadPending
  plus droppedSentCount/droppedFullCount counters for M4.6 observability
- New JsonlAnalyticsStore class: file-backed JSONL at
  {baseDir}/analytics-queue.jsonl
- Retry-cap policy lives inside updateStatus(_, 'failed'): increment attempts;
  stay 'pending' while attempts < MAX_ATTEMPTS; flip terminal 'failed' at cap;
  no overshoot on terminal rows
- Write serialization via private writeChain Promise mutex; eliminates
  appendFile vs readFile/rename interleave race for concurrent track + flush
- Atomic rewrite: tmp file + rename (mirrors FileQueryLogStore.writeAtomic)
- Append uses FileHandle.sync() for fsync durability so row survives daemon kill
- File-size cap (default 5000 rows / 10MB): drop oldest sent first; pending and
  failed never dropped; if cap full of pending+failed, append silent no-op
  plus droppedFullCount++
- list sort: (timestamp DESC, id DESC) for stable same-timestamp ordering
- Read methods (list, loadPending) skip the write chain; consistent snapshot
  via atomic rename
- Corruption tolerance: skip JSON.parse-failures and Zod-schema-failures
- 30 unit tests using tmpdir(); full suite 7554 passing
…sync (M9.3)

- Widen IAnalyticsQueue + BoundedQueue from AnalyticsEventWithIdentity to
  StoredAnalyticsRecord; id propagates through the queue
- AnalyticsClientDeps adds jsonlStore: IJsonlAnalyticsStore
- trackAsync: resolve identity + super-props, generate randomUUID id,
  compose StoredAnalyticsRecord{status:'pending', attempts:0},
  await jsonlStore.append() FIRST, then queue.push() on success
- JSONL append failure: queue NOT pushed, silent drop preserves
  "JSONL is source of truth" invariant; "MUST NOT crash consumer" honored
- feature-handlers.ts constructs ONE JsonlAnalyticsStore({baseDir:
  getGlobalDataDir()}) and injects into AnalyticsClient. Same instance
  will be shared with M11.2's analytics-list-handler
- 6 new unit tests covering dual-write happy path, JSONL-fail fallback,
  uuid distinctness, queue.size() = JSONL row count, disabled no-op
- All existing M2 AnalyticsClient + BoundedQueue tests pass with widened
  type
- Integration tests (daemon-tracking, transport) updated with real
  JsonlAnalyticsStore({baseDir: testDir}) per tmpdir convention
- Full suite 7562 passing
isAnalyticsEventName used `value in ALL_EVENT_SCHEMAS`, which matches inherited Object.prototype keys ('toString', 'constructor', '__proto__', ...). Such an event passed the guard, then ALL_EVENT_SCHEMAS[event].safeParse resolved to an inherited member (not a Zod schema) and threw at the wire-side dispatch, escaping the handler. Switch to Object.hasOwn (own-property only) so prototype-key events drop like any other unknown event. Add a regression test that asserts they drop without throwing.

Also refresh the AnalyticsClient class docblock to match the unconditional track() contract: local recording is unconditional, the isEnabled gate lives in flush() (remote send), not track().

Add test/e2e/analytics/mcp-tool-db.e2e.ts (npm run test:e2e:mcp): drives the real 'brv mcp' over JSON-RPC and verifies mcp_session_start / mcp_tool_called / mcp_session_ended land in postgres raw_events across brv-query and brv-curate roundtrips (anonymous), plus device_id rotation on auth:logout.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Claude finished @bao-byterover's task in 5m 43s —— View job


Claude PR Review — Analytics System (M1 → M17) — Seventeenth pass

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). The three new commits in this push are the most productive bundle since ENG-2658: one fixes a real anonymous-shipping bug (fresh users silently shipped nothing), one closes the long-standing 48-case dispatch-switch finding from the very first review (E), one closes a security-relevant prototype-pollution guard bypass, and a UX-quality push furthers the trust-separation framing via the "Analytics sharing" label change. Score held at 3/5 because the single ship-blocker from the prior sixteen reviews — lorem-ipsum disclosure body — is still in the tree.

Todos

  • Identify new commits since the sixteenth review (219da05, 1014a9f, b2274ea)
  • Re-check analytics-disclosure.md (16× prior ship-blocker — still lorem ipsum)
  • Spot-check three substantive new commits
  • Re-verify prior unresolved findings (Finding E closed)
  • Post inline issues + summary

What's new since the sixteenth review (3 substantive commits)

  • 219da05 — always-present device_id + single-source analytics:track dispatch. Three layered fixes:

    1. ensureDeviceId() on GlobalConfigHandler seeds the install-level device_id at daemon bootstrap (feature-handlers.ts:204-210) and again before each remote flush (wired through analytics-client.ts:404's new ensureDeviceId?: () => Promise<void> dep), independent of the analytics.share flag. Canonical seeder (ensureDeviceIdOn) is the single place a UUID is generated; doSetAnalytics now reuses it. Closes a real anonymous-shipping bug: previously device_id was only created when analytics.share flipped true, so a fresh / never-authed user recorded events with an empty device_id that the wire schema + sender dropped → silent zero-telemetry for new users.
    2. AnalyticsHandler dispatch consolidation — the 330-line, 48-case switch collapses to a 6-line table-driven dispatch via ALL_EVENT_SCHEMAS. New segregated IWireEventTracker interface (ISP-correct: handler depends on the one method it uses; track<E> test doubles unaffected). Closes Finding E (standing since pass 1) — also incidentally fixes silently dropped swarm_* events (pinned by a new test).
    3. CliMetadata type cleanup — removes the Record<string, unknown> & intersection that was a cast workaround the prior dispatch shape required.
  • 1014a9f — "Analytics: enabled/disabled" → "Analytics sharing: enabled/disabled" + TUI read-only detail panel.

    1. The status snapshot label correctly surfaces the trust-separation semantics (local tracking is unconditional; only sharing toggles). Third compounding piece alongside the analytics.share rename (ENG-3035) and the descriptor honesty at settings.ts:200. Further closes Finding O.
    2. New displayDetail field on SettingsRow carries the full multi-line value (analytics.status is multi-line by nature); list rows still render the headline-only displayCurrent. Enter on a read-only row opens an overlay; Esc returns.
  • b2274ea — prototype-pollution guard + MCP-tool DB e2e + docblock refresh.

    1. Security-relevant fix. isAnalyticsEventName switched from value in ALL_EVENT_SCHEMAS to Object.hasOwn(...). A client sending {event: 'toString'} previously passed the guard (in walks prototype chain) and crashed the handler at safeParse (Function.prototype.toString has no .safeParse). Excellent regression test walks five prototype keys and asserts both no-throw AND no-track-reached.
    2. AnalyticsClient class docblock refresh — explicitly documents the unconditional track() contract and includes "Do NOT reintroduce an isEnabled() gate here: that was the bug where fresh/never-authed users recorded nothing" — the kind of historical-failure-mode comment that forward-defends against drift.
    3. MCP-tool DB e2e — 690 lines driving real brv mcp over JSON-RPC, verifying the mcp_* trio + device_id rotation on logout. npm run test:e2e:mcp.

New findings on this pass

  1. analytics-disclosure.md — ship-blocking, seventeenth flag. No change. Bundle the lorem-ipsum body fix in both src/shared/assets/analytics-disclosure.md and src/webui/features/analytics/constants.ts:13-39 in the same PM/legal copy round.
  2. analytics-handler.ts:62 — praise. Finding E closed (standing since 1st review). 330 lines → 6 lines.
  3. events/index.ts:174 — praise (security). Prototype-pollution guard bypass closed.
  4. global-config-handler.ts:100 — praise + question. ensureDeviceId bootstrap+per-flush+canonical-seeder is the right shape; open question on whether a bootstrap+first-flush race is worth pinning with a test.
  5. format-analytics-status.ts:63 — praise + observation. "Analytics sharing" label closes Finding O further; JSON envelope's enabled key is unchanged — defensible (script callers) but worth a one-line decision record on text-vs-JSON asymmetry.
  6. settings-page.tsx:476 — praise + small observation. Detail overlay is the right UX shape; the displayCurrent fallback in renderDetailOverlay is structurally unreachable from the current Enter binding (Enter only routes readonly-info rows there).
  7. analytics-client.ts:94 — praise. Class-docblock refresh documents the unconditional track() contract and names the historical bug ("Do NOT reintroduce…") that the prior isEnabled() gate caused.

Prior findings — status

# File Finding Status
A analytics-disclosure.md + webui/features/analytics/constants.ts:13-39 Lorem-ipsum body ship-blocker, 17th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) user-facing copy resolved; URL inconsistency vs. WebUI remains
C http-analytics-sender.ts:62-69 No-deviceId returns no reason resolved by ENG-2658
D analytics-hook.ts:88 <outside-project>/${basename} leaks filename leaf resolved by ENG-2658
E analytics-handler.ts dispatch switch 48 cases scaling poorly vs. table-driven dispatch RESOLVED by 219da05 — table-driven via ALL_EVENT_SCHEMAS
F event-names.ts:32 MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — undocumented unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race unresolved (non-blocking)
I migrate-handler.ts:132-140 Brittle string-prefix match resolved by ENG-2658
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT ordering unresolved (non-blocking)
K format-analytics-status.ts:114 Import-time side-effect registration unresolved (non-blocking)
L webui/pages/configuration/general.tsx:3 Style nit resolved earlier
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes one hyphen apart resolved by ENG-2658
N onboarding.md:290-292 Verbal-script scope gap unresolved (bundled into disclosure-body fix round)
O onboarding.md:299-303 "Two promises" framing vs. code reality further mitigated by 1014a9f "Analytics sharing" label
P onboarding.md:305-308 No persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved earlier
R stored-record.ts:99-109 toWireEvent derivation caveats resolved earlier
S auth-handler.ts:453 Rotate-after-emit TOCTOU unresolved (non-blocking)
T i-global-config-rotator.ts:19 Non-idempotent contract undocumented unresolved (non-blocking)
14-2 analytics-client.ts:388-390 Rate-limited records bump retry-cap unresolved (non-blocking)
U webui/features/analytics/constants.ts:11 docs.byterover.dev URL drift unresolved (non-blocking)
V webui/features/analytics/constants.ts:13-39 Disclosure body duplicated folded into Finding A
16-NEW settings-keys.ts:13 Value-only rename leaves identifier/value drift unresolved (non-blocking)

What's good in this push

  • Finding E is closed. The 48-case switch was first flagged on the 1st review pass; it has now collapsed into a 6-line table-driven dispatch backed by ALL_EVENT_SCHEMAS as the single source of truth. AnyAnalyticsEvent, PropsForEvent, PropsArg, the runtime guard, and the handler's per-event validation all derive from the same map. No drift surface.
  • Anonymous-shipping fixed. ensureDeviceId at bootstrap means a fresh / never-authed user's events now carry a real device_id from the very first track, and the per-flush belt-and-suspenders guards against runtime config wipes — both via the same canonical seeder, idempotent and serialized through writeChain.
  • Prototype-pollution guard. Object.hasOwn instead of in is a 7-character fix that closes a class of TypeError-throwing crashes. The regression test discipline (walks five prototype keys, asserts no-throw + no-track-reached) is exactly the shape that prevents the same bug from creeping back in.
  • Trust-separation framing now triple-anchored. Key name (analytics.share) + descriptor description (settings.ts:200 "Local tracking is always on") + status snapshot label ("Analytics sharing: enabled/disabled") all linguistically consistent. The eighth-review finding O is meaningfully closer to closed.
  • AnalyticsClient docblock refresh. "Do NOT reintroduce an isEnabled() gate here: that was the bug where fresh/never-authed users recorded nothing and shipped no telemetry" — names the historical failure mode in the place where a future maintainer would reach for the optimization. Forward-defended.
  • MCP-tool DB e2e rounds out the lifecycle-db.e2e coverage that previously couldn't exercise clientType === 'mcp' paths. The brv-query / brv-curate roundtrips run LLM-free (deterministic) so the test is reproducible without provider credentials.

Items outside the inline scope (carried forward)

  • WebUI typecheck still broken per PR description — confirm @campfirein/byterover-packages bump before merge or husky pre-push blocks.
  • build:ui not re-verified — gated on the same package bump.
  • x-byterover-session-id header in axios-analytics-http-client.ts:81 — backend contract note still pending.

Verdict

One ship-blocker, unchanged for the seventeenth review in a row. Replace lorem-ipsum disclosure copy in both src/shared/assets/analytics-disclosure.md and src/webui/features/analytics/constants.ts:13-39 with PM/legal-approved text. Resolve the byterover.dev/privacy vs. docs.byterover.dev/privacy URL drift (Finding U) in the same round. Every other finding is recommended-but-not-blocking.

The three new commits represent the most substantive review-response bundle since ENG-2658: Finding E (standing since the 1st review) is fully closed, the anonymous-shipping bug for fresh users is fixed at root, and a security-relevant prototype-pollution guard bypass is closed with a regression test. The disclosure-body lorem ipsum is now the only substantive item between this branch and merge.
· branch: proj/analytics-system-tool-mode

Comment thread src/shared/assets/analytics-disclosure.md Outdated
Comment thread src/server/infra/transport/handlers/analytics-handler.ts
Comment thread src/shared/analytics/events/index.ts
Comment thread src/server/infra/transport/handlers/global-config-handler.ts
Comment thread src/shared/utils/format-analytics-status.ts
Comment thread src/tui/features/settings/components/settings-page.tsx
Comment thread src/server/infra/analytics/analytics-client.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Claude finished @bao-byterover's task in 1m 37s —— View job


Claude PR Review — Analytics System (M1 → M17) — Eighteenth pass

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). No analytics-substantive changes since the seventeenth review — the only new commit on this branch (5dc2457) is a merge from main that brought in unrelated curate-protocol / html-renderer work. Score held at 3/5 because the single ship-blocker from the prior seventeen reviews — lorem-ipsum disclosure body — is still in the tree.

Todos

  • Identify new commits since the seventeenth review (5dc2457 — merge from main, no analytics surface delta)
  • Re-check analytics-disclosure.md (17× prior ship-blocker — still lorem ipsum across all five sections)
  • Spot-check changes in this push (verified merge content is curate / html-reader / html-renderer, no analytics files)
  • Re-verify prior unresolved findings (no movement on any open finding this pass)
  • Post inline issue + summary

What's new since the seventeenth review

Only 5dc2457 — a merge from main pulling in unrelated work: curate/index.ts (367 lines), curate-session.ts (171 lines), html-reader.ts (40 lines), html-renderer.ts (80 lines), system-prompt.yml, plus the matching test files. Zero touch on any src/server/infra/analytics/, src/shared/analytics/, or src/webui/features/analytics/ file. The analytics surface is exactly where it was at the end of pass 17.

Standing ship-blocker — unresolved, eighteenth flag

src/shared/assets/analytics-disclosure.md (lines 3-34) + src/webui/features/analytics/constants.ts:13-39. Both surfaces still ship literal Lorem ipsum bodies. Verified by reading both files at the head of this PR. The fix is two files, no external dependency, no submodule bump, no agent-team handoff. With the analytics.share rename in place, the descriptor honesty at settings.ts:200, the "Analytics sharing" status label, and the device_id rotation/seeding fixes from 219da05 + b2274ea, the PM/legal round has unusually clean foundations to lean on.

Inline →

Prior findings — status

# File Finding Status
A analytics-disclosure.md + webui/features/analytics/constants.ts:13-39 Lorem-ipsum body ship-blocker, 18th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) user-facing copy resolved; URL inconsistency vs. WebUI remains
C http-analytics-sender.ts:62-69 No-deviceId returns no reason resolved by ENG-2658
D analytics-hook.ts:88 <outside-project>/${basename} leaks filename leaf resolved by ENG-2658
E analytics-handler.ts dispatch switch 48 cases scaling poorly resolved by 219da05
F event-names.ts:32 MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — undocumented unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race unresolved (non-blocking)
I migrate-handler.ts:132-140 Brittle string-prefix match resolved by ENG-2658
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT ordering unresolved (non-blocking)
K format-analytics-status.ts:114 Import-time side-effect registration unresolved (non-blocking)
L webui/pages/configuration/general.tsx:3 Style nit resolved earlier
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes one hyphen apart resolved by ENG-2658
N onboarding.md:290-292 Verbal-script scope gap unresolved (bundled into disclosure-body fix round)
O onboarding.md:299-303 "Two promises" framing vs. code reality further mitigated by 1014a9f "Analytics sharing" label
P onboarding.md:305-308 No persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved earlier
R stored-record.ts:99-109 toWireEvent derivation caveats resolved earlier
S auth-handler.ts:453 Rotate-after-emit TOCTOU unresolved (non-blocking)
T i-global-config-rotator.ts:19 Non-idempotent contract undocumented unresolved (non-blocking)
14-2 analytics-client.ts:388-390 Rate-limited records bump retry-cap unresolved (non-blocking)
U webui/features/analytics/constants.ts:11 docs.byterover.dev URL drift unresolved (non-blocking)
V webui/features/analytics/constants.ts:13-39 Disclosure body duplicated folded into Finding A
16-NEW settings-keys.ts:13 Value-only rename leaves identifier/value drift unresolved (non-blocking)

Items outside the inline scope (carried forward)

  • WebUI typecheck still broken per PR description — confirm @campfirein/byterover-packages bump before merge or husky pre-push blocks.
  • build:ui not re-verified — gated on the same package bump.
  • x-byterover-session-id header in axios-analytics-http-client.ts:81 — backend contract note still pending.

Verdict

One ship-blocker, eighteenth review. Replace lorem-ipsum disclosure copy in both src/shared/assets/analytics-disclosure.md and src/webui/features/analytics/constants.ts:13-39 with PM/legal-approved text. Resolve the byterover.dev/privacy vs. docs.byterover.dev/privacy URL drift (Finding U) in the same round. Every other open finding is recommended-but-not-blocking — Findings C, D, E, I, M, Q, R have all been closed across prior passes, and Finding O is meaningfully mitigated by the ENG-3035 rename + "Analytics sharing" label.

The engineering substance of this branch is excellent. The merge readiness is one PM/legal copy round away.
· branch: proj/analytics-system-tool-mode

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Event names and
super properties (`device_id`, `cli_version`, `os`, `node_version`,
`environment`) are recorded. No content of your queries, files, or
memory is collected.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (ship-blocking — eighteenth flag): Unchanged for the eighteenth review in a row. Every section body still opens with literal Lorem ipsum. The only commit since pass 17 (5dc2457) is a merge from main that brought in unrelated curate-protocol / html-renderer work — no new analytics surface changes, no movement on the disclosure body.

Four formal consent surfaces (oclif text-mode brv settings set analytics.share true, oclif text+--yes, TUI confirm-disclosure overlay, agent-driven --yes from the onboarding skill at onboarding.md:296) plus the WebUI Privacy panel default-render this lorem ipsum text (src/webui/features/analytics/constants.ts:13-39).

This remains the only substantive ship-blocker on the PR. The fix is two files — no external dependency, no submodule bump, no agent-team handoff. With the analytics.share rename now in place, the descriptor honesty at settings.ts:200 ("Local tracking is always on"), the "Analytics sharing" status label, and the device_id rotation/seeding fixes from 219da05 + b2274ea, the PM/legal round has unusually clean foundations to lean on.

Fix this →

…eal text; drop brittle content-assertion test.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Claude finished @leehpham's task in 3m 41s —— View job


Claude PR Review — Analytics System (M1 → M17) — Nineteenth pass

Overall score: 4 / 5 — The headline ship-blocker from the prior eighteen reviews is finally closed in the canonical disclosure markdown (125b5c4). PM/legal-approved copy replaces every lorem-ipsum body, the trust-separation framing carries through cleanly ("the CLI keeps a local record of activity regardless of this setting; enabling analytics only controls whether anonymous events are shared"), and the disable command form points at the post-rename analytics.share key. Score moves 3 → 4 because:

  • Two of three consumer paths (oclif collectConsent, TUI confirm-disclosure) now render real disclosure copy via loadAnalyticsDisclosureText().
  • The third path — WebUI Privacy panel — still ships lorem ipsum (src/webui/features/analytics/constants.ts:13-39) and default-renders it (detailsOpen = true from 5d96619) on every Privacy tab visit. This is a real user-visible bug today; the canonical markdown round was a half-fix.
  • Privacy URL drift (Finding U from pass 13) unresolved: canonical disclosure now points at byterover.dev/privacy; WebUI still points at docs.byterover.dev/privacy.
  • Content-assertion test deleted with the rationale "drop brittle content-assertion test" — the 36-line contract test verified five section headers + privacy URL + disable command form. Was minimal, not brittle; removing it leaves both disclosure surfaces unguarded against future copy drift.

Todos

  • Identify new commits since the eighteenth review (125b5c4 — disclosure copy + test deletion)
  • Verify the ship-blocker: canonical markdown is real copy (✅); WebUI constants are still lorem ipsum (❌)
  • Spot-check 125b5c4 — markdown copy is strong; deleted test is a meaningful guard
  • Audit consumer paths: oclif + TUI load canonical (✅); WebUI loads its own lorem-ipsum constants (❌)
  • Cross-check verbal script — still has the gaps from pass 8/16
  • Re-verify prior unresolved findings (Finding A half-closed; Finding U still open)
  • Post inline issues + summary

What's new since the eighteenth review (1 substantive commit)

  • 125b5c4 (ENG-2618) — Replace placeholder analytics disclosure copy with real text; drop content-assertion test.
    • src/shared/assets/analytics-disclosure.md: every section body replaced with real PM/legal-approved copy. Preserves all five required headers (What is collected, Which surfaces are tracked, Where it goes, Cross-device alias, How to disable) plus the Privacy policy link. Lead paragraph carries the trust-separation framing the descriptor + key-name renames have been building toward.
    • test/unit/shared/assets/analytics-disclosure-content.test.ts: deleted (36 lines, 6 regex assertions + 1 URL include + 1 negation).
    • src/webui/features/analytics/constants.ts:13-39: not touched — still lorem ipsum.

New findings on this pass

  1. src/webui/features/analytics/constants.ts:13-39 — Finding A WebUI half still open, ship-pending. The canonical markdown shipped real copy in 125b5c4, but this parallel source of truth — rendered by DisclosureDetails inside the default-open AnalyticsPanel Privacy tab — still ships Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore. for every one of its five section bodies. A user landing on the WebUI Privacy tab today sees lorem-ipsum boilerplate by default; the toggle ships, the copy doesn't.

  2. Canonical markdown enumeration gaps vs. ALL_EVENT_SCHEMAS (non-blocking, additive copy round):

    • space_id / team_id (ENG-3020, pass 10) — backend-resolvable identifiers stamped on curate_run_completed and query_completed. Not mentioned in "What is collected".
    • device_id rotation on logout / account-switch / refresh-failure sign-out (ENG-3019, pass 12) — a privacy-positive feature that softens the "permanently linked" claim. Not mentioned in "Cross-device alias".
  3. Privacy URL drift (Finding U pass 13, still open). Three URLs in the codebase that should agree on one canonical target:

    • shared/constants/privacy.ts:6byterover.dev/privacy
    • analytics-disclosure.md:56 (new copy) → byterover.dev/privacy
    • webui/features/analytics/constants.ts:11 + analytics-panel.tsx:111docs.byterover.dev/privacy

    Plus: shared/constants/privacy.ts:1-5 docblock still annotates the URL as Placeholder until M1.5 — contradictory with the now-shipped canonical disclosure that treats it as real.

  4. Deleted contract test — minimal, not brittle. The 36-line test pinned five section headers + privacy URL + disable command form. That's the contract PM/legal sign off on at landing; removing it leaves future copy edits unguarded. Recommend restoring (cheap) or upgrading to a cross-source consistency test (would have caught the WebUI lorem-ipsum gap).

  5. Verbal-script gaps (pass 8/16 findings N) — now more visible. With the canonical disclosure shipping real copy, the verbal script in onboarding.md:290-296 is now materially narrower than the markdown it precedes:

    • "Things like which commands ran and how long they took" still undersells super-properties (no device_id, os, space_id, team_id).
    • "Cross-device alias" still unmentioned (the new markdown's "permanently linked to your ByteRover account" is a real one-way commitment).
    • "You can change your mind anytime" / "reverses it anytime" still overpromises against the markdown's permanence claim.

Prior findings — status

# Finding Status
A Lorem-ipsum disclosure body PARTIALLY RESOLVED — canonical markdown done (125b5c4), WebUI half still open
B PRIVACY_POLICY_URL placeholder annotation docblock at privacy.ts:1-5 still says "Placeholder until M1.5" — now contradictory with shipped disclosure
C-D, I, M, Q, R, E Various correctness / quality fixes resolved in prior passes
F MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G Sequential pre-change listener — undocumented unresolved (non-blocking)
H pendingTracks.add micro-race unresolved (non-blocking)
J Synthetic TOOL_CALL → TOOL_RESULT FIFO assumption unresolved (non-blocking)
K Import-time side-effect registration unresolved (non-blocking)
N Verbal-script scope gap unresolved — gaps more visible now that canonical copy is real
O "Two promises" framing resolved — new disclosure lines 19-22 make trust separation explicit
P No persisted declined state in onboarding ask unresolved (non-blocking)
S Rotate-after-emit TOCTOU on device_id unresolved (non-blocking)
T Non-idempotent rotateDeviceId undocumented unresolved (non-blocking)
U docs.byterover.dev/privacy URL drift unresolved (carried 7 passes now)
V Disclosure body duplicated across markdown + WebUI folded into A — duplication still exists, half is real, half is lorem ipsum
14-2 Rate-limited records bump retry-cap unresolved (non-blocking)
16-NEW analytics.share value/identifier drift unresolved (non-blocking)

What's good in this push

  • Disclosure copy quality. The new markdown is tight, scoped, and honest. The lead paragraph names what is and isn't shared in one sentence; the trust-separation framing carries through every section; the enumeration of super-properties is bulleted rather than buried; the disable instruction uses the post-rename key name.
  • Three-surface agreement on the toggle's semantics. The new disclosure body + the descriptor description at settings.ts:200 ("Local tracking is always on") + the analytics.share key name + the "Analytics sharing" status label all now linguistically consistent. A user reading any one surface first encounters the same mental model.
  • Section headers stable across the rewrite. Although the test was deleted, the headers in the new copy match the deleted test's expectations — which means a restoration of the contract test (recommended in inline) would pass today.

Verdict

Half-closed ship-blocker. The canonical disclosure markdown round is the work this branch has been waiting nineteen reviews for, and it landed well. To close the remaining half before merge: (1) replace the lorem-ipsum bodies in src/webui/features/analytics/constants.ts:13-39 with copy that mirrors the canonical markdown (or single-source via a vite-side markdown loader), (2) align webui/features/analytics/constants.ts:11 + analytics-panel.tsx:111 with the canonical PRIVACY_POLICY_URL constant, (3) drop the (Placeholder until M1.5) annotation in shared/constants/privacy.ts:1-5 since the disclosure now ships against it as canonical, and (4) consider restoring the deleted contract test or upgrading to a cross-source consistency assertion.

The enumeration gaps (space_id/team_id, device_id rotation) and the verbal-script alignment are additive copy rounds — recommended but not blocking. Items C, D, E, I, M, Q, R have all been closed across prior passes; the remaining open findings are all non-blocking.
· branch: proj/analytics-system-tool-mode

Comment on lines +13 to +39
export const ANALYTICS_DISCLOSURE_SECTIONS: readonly AnalyticsDisclosureSection[] = [
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Database,
label: 'WHAT IS COLLECTED',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Eye,
label: 'WHICH SURFACES ARE TRACKED',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Server,
label: 'WHERE IT GOES',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Link2,
label: 'CROSS-DEVICE ALIAS',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: PowerOff,
label: 'HOW TO DISABLE',
},
] as const
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (ship-blocking — Finding A, WebUI half still open): 125b5c4 replaced the canonical disclosure markdown (src/shared/assets/analytics-disclosure.md) with real PM/legal-approved copy — good. But this file, which is the other source of disclosure body content, still ships literal Lorem ipsum dolor sit amet... for every one of its five section bodies.

The AnalyticsPanel (analytics-panel.tsx:21) defaults detailsOpen to true (from 5d96619, flagged in the 13th review). So every user landing on the WebUI Privacy tab sees these lorem-ipsum bodies by default — even though oclif + TUI now render the real text via loadAnalyticsDisclosureText(). Two of three consumer paths (oclif collectConsent, TUI confirm-disclosure) get real copy; the third (WebUI default-open Privacy panel) still gets lorem ipsum.

The fix should mirror the canonical markdown's five sections (What is collected / Which surfaces are tracked / Where it goes / Cross-device alias / How to disable) with the same content. Better still: a vite-side markdown loader that single-sources src/shared/assets/analytics-disclosure.md and renders it into the AnalyticsDisclosureSection[] shape — closes the drift hazard architecturally so the next PM/legal copy edit (and there will be one — see the enumeration gap below) touches one file, not two.

When updating, also cover the gaps the canonical markdown still has vs. ALL_EVENT_SCHEMAS:

  • space_id / team_id are now stamped on curate_run_completed and query_completed (ENG-3020, pass 10) but neither the WebUI bodies nor the canonical markdown's "What is collected" section enumerates them.
  • device_id rotation behavior (ENG-3019, pass 12) — rotates on logout, account-switch login, and refresh-failure sign-out — is a privacy-positive feature that softens the "permanently linked" claim in "Cross-device alias" but is currently missing from both surfaces.

Fix this →

- `cli_version` — the installed CLI version
- `os` — your operating system platform
- `node_version` — the Node.js runtime version
- `environment` — whether the build is development or production
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise + content gap (non-blocking but bundle-friendly): Excellent close on the longest-standing finding in this PR's review history. The new copy carries the trust-separation framing all the way through ("the CLI keeps a local record of activity regardless of this setting; enabling analytics only controls whether anonymous events are shared with ByteRover" at lines 20-22), which precisely matches the descriptor honesty at settings.ts:200 and the analytics.share rename — three surfaces now agree linguistically about what the toggle does.

Two specific enumeration gaps worth folding into a follow-up copy round (non-blocking; they're additive):

  1. space_id and team_id. These were added by ENG-3020 (pass 10 review) and now stamp on curate_run_completed and query_completed payloads. Backend-resolvable identifiers materially expand what an authenticated user is sharing vs. the anonymous identifier list above; they deserve enumeration alongside device_id / cli_version / os / node_version / environment.

  2. device_id rotation behavior under "Cross-device alias". ENG-3019 (pass 12) rotates the device_id on three identity transitions: explicit brv logout, account-switch login, and refresh-failure sign-out. That's a privacy-positive feature that softens the "permanently linked" claim at line 39-40 — a future anonymous session after logout does NOT inherit the prior device's history. Naming it both honors the engineering work and gives the user a clearer mental model of the alias semantics.

A one-paragraph addition to "Cross-device alias" along the lines of "When you log out, the device's anonymous identifier is retired and a fresh one is generated for the next session — so prior anonymous activity stays linked to the account you used, and the next anonymous run starts fresh" would close the gap cleanly.

label: string
}

export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (Finding U from pass 13, still unresolved): Two different canonical privacy URLs now ship:

Source URL
src/shared/constants/privacy.ts:6 https://byterover.dev/privacy
src/shared/assets/analytics-disclosure.md:56 (new copy as of 125b5c4) https://byterover.dev/privacy
This file https://docs.byterover.dev/privacy
analytics-panel.tsx:111 (display text) docs.byterover.dev/privacy

The disclosure-markdown round (great work landing the real copy) explicitly committed to byterover.dev/privacy as the canonical destination. The WebUI Privacy panel still points users to a different host — if docs.byterover.dev/privacy resolves to documentation rather than the canonical legal privacy policy, the consent surfaces are pointing at different pages. Even if both resolve identically today, the divergence is invisible to tests and silently grows the next time either page is renamed.

Cleanest fix: import the canonical constant rather than re-declaring it locally:

Suggested change
export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy'
import {PRIVACY_POLICY_URL as ANALYTICS_PRIVACY_URL} from '../../../shared/constants/privacy'

(Display string at analytics-panel.tsx:111 needs the same alignment — byterover.dev/privacy, not docs.byterover.dev/privacy.)

Also worth catching while the disclosure file is open: shared/constants/privacy.ts:1-5's docblock still annotates the URL as Placeholder until M1.5. Since the M1 disclosure copy has now shipped and points at this URL, the comment is stale — either the URL is canonical (drop the placeholder note) or it's still pending (block on the doc-page landing). Today's text is contradictory between the new disclosure markdown (treats it as canonical) and the constants file (treats it as placeholder).

@@ -0,0 +1,56 @@
# ByteRover CLI Analytics Disclosure
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (test coverage, non-blocking): 125b5c4 deleted test/unit/shared/assets/analytics-disclosure-content.test.ts with the commit-message rationale "drop brittle content-assertion test." Reading the deleted test (only 36 lines), it was actually quite minimal — six regex matches against section headers, one include check for PRIVACY_POLICY_URL, and one negation that the deleted brv analytics disable command form never reappears.

I don't think it was brittle in the high-maintenance sense — it's a contract test that says "the disclosure has these five sections plus the privacy link." That contract is what PM/legal sign off on at landing time; if a future copy edit accidentally drops "Cross-device alias" or rewords the disable command to a deprecated form, this test would have caught it loudly.

With both this test removed AND the WebUI lorem-ipsum still present (separate finding), the disclosure copy now lives in two unguarded sources of truth. The next copy edit could silently drift them apart without any test failure. Two cheap paths back:

  1. Restore the section-header contract — keep just the regex assertions for section names + PRIVACY_POLICY_URL + the disable command form. The test is 30 lines; the maintenance burden is "update if PM intentionally renames a section."

  2. Cross-source consistency test — assert that every section label in ANALYTICS_DISCLOSURE_SECTIONS (WebUI) matches a section header in analytics-disclosure.md. Would have prevented the lorem-ipsum-in-WebUI gap from shipping unnoticed.

Either gives back the audit guarantee the deleted test provided. Non-blocking but worth weighing — content-assertion tests on legal-premise copy are usually exactly the kind of low-churn / high-value test that pays compound interest.

ncnthien added 8 commits June 1, 2026 18:01
Replace the docs.byterover.dev placeholder with the canonical
https://www.byterover.dev/services/privacy URL now that the public page
has been published. Drops the stale "placeholder until M1.5" comment —
the URL is no longer a placeholder.
…ender it in the webui

Single source of truth for the disclosure copy lives in
src/shared/assets/analytics-disclosure.md and was already consumed by the
CLI consent prompt via loadAnalyticsDisclosureText(). The webui's Privacy
panel had a separate hardcoded copy that drifted out of sync once the real
PM/legal copy landed.

- Add `analytics:getDisclosure` transport event + Zod response schema.
- New AnalyticsDisclosureHandler wraps loadAnalyticsDisclosureText() and
  exposes it over the transport; wired into feature-handlers.ts alongside
  the other AnalyticsHandler family.
- Webui `useGetAnalyticsDisclosure` query hook + render via the existing
  MarkdownView from the contexts feature so lists, code blocks, and links
  in the markdown render properly.
- Delete the now-obsolete ANALYTICS_DISCLOSURE_SECTIONS / icon grid and
  the ANALYTICS_PRIVACY_URL duplicate; analytics-panel.tsx now consumes
  PRIVACY_POLICY_URL directly from src/shared/constants/privacy.ts.
- Drop the constants test file along with the hardcoded data it covered.
- Derive the visible privacy link label from PRIVACY_POLICY_URL so the URL
  is the single source of truth — the label can no longer drift on a
  future URL change.
- Mark the disclosure query as staleTime: Infinity. The markdown is a
  bundled asset that never changes at runtime, so refetching on focus is
  pointless; making the cache-forever semantic explicit avoids confusion.
- Guard against an empty disclosure body in AnalyticsDisclosureHandler:
  throw a visible error instead of returning an empty string the webui
  would silently render as a blank panel. Test added for the empty case.
…on grid layout

Reviewer rejected dumping the full markdown body into the Privacy panel —
the original M1.6 icon-grid layout should be preserved. Move the markdown
-> sections conversion daemon-side so the webui still gets structured data
without owning a copy of the section labels.

- New parseAnalyticsDisclosure() helper splits the markdown on ## H2
  headings into {label, body}[]. Intro paragraphs above the first H2 and
  any H3+ nested headings stay inside their parent section verbatim.
- Updated AnalyticsDisclosureResponseSchema to return sections instead of
  the raw markdown string. Handler throws if the markdown has zero H2s
  (single-source-of-truth invariant still surfaced as an error toast).
- DisclosureDetails reverts to the 2-column icon grid (Database / Eye /
  Server / Link2 / PowerOff mapped by section index), filters out the
  "Privacy policy" section (already shown as the footer link), and uses
  the parsed label + body. Labels render uppercased to match the design.
- Hook + handler tests updated for the new shape; new parser test covers
  multi-paragraph bodies, code blocks, no-H2 input, and nested headings.
…icon grid

The previous commit rendered each section's body as a plain <p>, so real
markdown tokens (**bold**, inline `code`, fenced code blocks, bullet lists)
leaked into the UI as raw text — see the screenshot in the PR review.

Swap the <p> for the existing MarkdownView (already used by the contexts
feature) with a small wrapper class that keeps the cell text styling
(muted, 0.8125rem, leading-relaxed). Bold / inline code / lists / fenced
code now render correctly; cell heights are still variable but the
content reads cleanly.
…tring

- The disclosure markdown is a bundled asset that never changes at runtime,
  but the handler re-loaded and re-parsed it on every analytics:getDisclosure
  request. Lazy-cache the parsed sections on first success so subsequent
  requests are a single field read.
- Drop the multi-line WHAT docstring on the handler class per the
  zero-comments codebase convention. Single-source-of-truth context is
  already covered by the load + parse pipeline reading the markdown asset.
- New test pins the caching contract: three back-to-back requests trigger
  exactly one load. Existing error-propagation tests still cover the
  load-failure path (errors don't poison the cache).
- Switch icon-grid mapping from positional (SECTION_ICONS[index]) to a
  label-keyed Record with an Info fallback. PM/legal can now reorder or
  insert sections in the markdown without silently shifting every
  following icon to the wrong section.
- Use a substring "privacy" match for the icon-grid filter instead of an
  exact-string compare. Survives renames to "Privacy notice", "Privacy
  Policy & Terms", etc. so the section never accidentally leaks into the
  grid next to the footer link.
- Parser now drops sections with an empty body so the schema's body.min(1)
  matches the runtime contract — a stray "## Foo\n\n## Bar" can no longer
  render a blank labelled card.
- Tag the analytics-disclosure.md fenced code block with `bash` so the
  CodeBlock header is no longer empty in the webui (and the CLI prompt
  gets the language label too).
feat: [ENG-2621] serve analytics disclosure markdown via daemon + canonicalise privacy URL
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

Claude finished @ncnthien's task in 4m 50s —— View job


Claude PR Review — Analytics System (M1 → M17) — Twentieth pass

Overall score: 5 / 5The single ship-blocker that stood through 19 prior reviews is closed. Lorem-ipsum disclosure body is gone from both surfaces, the privacy URL drift is resolved, and the WebUI now consumes the same canonical markdown the CLI does — via a clean daemon transport event with cached parsing and explicit error propagation. This is genuinely merge-eligible.

Todos

  • Identify new commits since the nineteenth review (e8de448, 94b1cae, 1275ff0, e4f8b10, 8cb7599, ed90d74, 1aa060b, plus merge a146b79)
  • Verify disclosure copy state — real text in analytics-disclosure.md; ANALYTICS_DISCLOSURE_SECTIONS deleted from WebUI
  • Spot-check the new daemon transport + parser + caching wiring
  • Re-verify carry-over findings (URL drift U: resolved; placeholder annotation B: resolved; duplicate WebUI bodies V: resolved)
  • Post inline issues + summary

What's new since the nineteenth review (7 ENG-2621 commits + merge)

  • e8de448Finding U + B resolved. PRIVACY_POLICY_URL repointed at canonical https://www.byterover.dev/services/privacy; the stale Placeholder until M1.5 docblock is gone.
  • 94b1caeFinding A WebUI half + Finding V resolved. Adds analytics:getDisclosure transport event + AnalyticsDisclosureHandler that parses the canonical markdown into sections daemon-side; WebUI's DisclosureDetails now consumes via useGetAnalyticsDisclosure; the ANALYTICS_DISCLOSURE_SECTIONS constant array + ANALYTICS_PRIVACY_URL duplicate URL + the corresponding constants test are all deleted. Single source of truth achieved.
  • 1275ff0 — Review-response: URL label derived from PRIVACY_POLICY_URL.replace(/^https?:\/\/(www\.)?/, '') so the visible text can't drift from the href; query gets staleTime: Infinity (markdown is a bundled asset, refetching is pointless); handler explicitly throws when the disclosure parses to zero sections rather than serving empty.
  • e4f8b10 — Parses markdown H2 sections + restores the icon-grid layout consuming the parsed {label, body} shape.
  • 8cb7599 — Section bodies render via the existing MarkdownView (used by contexts feature) so **bold**, inline `code`, fenced blocks, and lists render correctly instead of leaking as raw text.
  • ed90d74 — Lazy-cache the parsed sections on first success; pinned test confirms three back-to-back requests trigger exactly one load.
  • 1aa060b — Robustness pass: icon mapping switches from positional SECTION_ICONS[index] → label-keyed Record with Info fallback (PM/legal can reorder sections without silently shifting icons); the 'privacy' filter is substring (survives renames to "Privacy notice" etc.); parser drops empty-body sections so the schema's body.min(1) matches runtime; markdown code fence tagged bash.

Plus the merge-commit a146b79 bringing #752 into the branch.

New findings on this pass

  1. shared/assets/analytics-disclosure.md:56 — minor URL form discrepancy (non-blocking). Constant uses www.byterover.dev/services/privacy; markdown uses apex byterover.dev/services/privacy. WebUI display strips www. for visual parity, but a copy-paste from the CLI markdown render lands at the apex URL while a WebUI click lands at the www. URL. Operationally fine if DNS does the standard 301; cosmetically inconsistent. Inline →

  2. analytics-disclosure-handler.ts:44 — substantive praise. This handler is the close-out of the 19-pass ship-blocker. Tight DI shape, lazy-cache on success, explicit error propagation, defense-in-depth empty-disclosure throw, five-surface test pinning. Plus a minor non-blocking parser observation: the split('\n') would leave stray \rs if the asset is ever committed CRLF — worth pinning the file to LF via .gitattributes. Inline →

  3. disclosure-details.tsx:25 — praise + minor observation. The substring-match privacy filter + label-keyed icon mapping are the right defensive shape; both deserve a one-line code comment surfacing the markdown-coupling assumption so a future "Privacy controls" section doesn't get silently filtered out and label renames don't silently fall back to Info. Inline →

Prior findings — status

# Finding Status
A Lorem-ipsum disclosure body in both analytics-disclosure.md + WebUI constants RESOLVED — canonical markdown shipped real copy in 125b5c4; WebUI half closed by 94b1cae (constants deleted, WebUI consumes the same markdown via daemon transport)
B PRIVACY_POLICY_URL (placeholder until M1.5) annotation RESOLVED by e8de448
U docs.byterover.dev/privacy URL drift RESOLVED by e8de448 + 94b1cae (now byterover.dev/services/privacy, WebUI imports from shared/constants/privacy.ts directly)
V Disclosure body duplicated across markdown + WebUI RESOLVED by 94b1cae (WebUI constant deleted, single markdown source)
C / D / E / I / M / Q / R Correctness + quality fixes Resolved in prior passes
N Verbal-script scope gap in onboarding.md:290-292 unresolved — verbal script narrower than canonical disclosure; with real markdown now shipping, --yes invocation at least logs real text
O "Two promises" framing mitigated by analytics.share rename + descriptor honesty + "Analytics sharing" status label + the new real disclosure body (lines 19-22: "the CLI keeps a local record of activity regardless of this setting; enabling analytics only controls whether anonymous events are shared")
P No persisted declined state unresolved (non-blocking)
F MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G Sequential pre-change listener serial-ordering rationale unresolved (non-blocking)
H pendingTracks.add micro-race unresolved (non-blocking)
J Synthetic TOOL_CALL → TOOL_RESULT FIFO assumption unresolved (non-blocking)
K Import-time side-effect registration unresolved (non-blocking)
L general.tsx style nit Resolved earlier (moved to Privacy tab)
S Rotate-after-emit TOCTOU on device_id unresolved (non-blocking)
T Non-idempotent rotateDeviceId contract undocumented unresolved (non-blocking)
14-2 Rate-limited records bump retry-cap unresolved (non-blocking)
16-NEW analytics.share value/identifier drift on settings-keys.ts:13 unresolved (non-blocking)
19-NEW Deleted disclosure-content contract test (no longer guards against future copy drift) mitigatedanalytics-disclosure-handler.test.ts + parse-analytics-disclosure.test.ts now pin the parser contract, caching, and error paths. Section-header rename detection still not guarded; the icon-mapping fallback to Info is the only visible signal.

What's good in this push (ENG-2621)

  • Single source of truth. PM/legal now edits one file (src/shared/assets/analytics-disclosure.md) and the change propagates to oclif collectConsent, TUI confirm-disclosure overlay, and WebUI Privacy panel — no parallel constant arrays, no risk of forgetting one consumer on the next copy round.
  • Defensive parser + handler. Caching on success, explicit throw on empty parse, error propagation that doesn't poison the cache, body-empty drop matching the schema's body.min(1), code fence tagged so the WebUI code block header isn't blank. Every layer fails loudly rather than silently.
  • Label-keyed icon mapping. PM/legal can reorder sections without silently mis-mapping icons (the prior positional SECTION_ICONS[index] shape would have shifted every following icon to the wrong section). Info fallback degrades gracefully on rename.
  • Substring privacy filter. 'privacy' substring match survives "Privacy notice" / "Privacy Policy & Terms" renames so the section never leaks into the icon grid next to the footer link.
  • Markdown-rendered bodies. Bold / inline code / lists / fenced blocks render correctly via the existing MarkdownView instead of leaking as raw ** tokens — disclosure copy can use the same formatting freedom PM/legal expect from a markdown asset.
  • Test discipline. Five-surface handler test (registration, parse, cache, empty-disclosure throw, loader-error propagation), six-case parser test (H2 extraction, multi-paragraph bodies, no-H2-empty, whitespace trim, empty-body drop, H3+ ignore). The deleted analytics-disclosure-content.test.ts content contract is mostly absorbed by these — section-header rename detection is the one remaining gap, mitigated by the icon-Info-fallback being visible.

Items outside the inline scope (carried forward)

  • WebUI typecheck still flagged in the PR description — @campfirein/byterover-packages bump and husky pre-push gate not re-verified in this review pass.
  • build:ui not re-verified — gated on the same package bump.
  • x-byterover-session-id header note for backend contract still pending.

Verdict

Ship-eligible. The 19-pass lorem-ipsum ship-blocker is closed. The privacy URL is canonical. The single-source-of-truth refactor closes Findings A / U / V together, with defensive caching, error propagation, and rendering robustness pass already layered in (1aa060b). Findings carrying forward (N / O / P / F / G / H / J / K / S / T / 14-2 / 16-NEW / 19-NEW-mitigated) are all genuinely non-blocking — they're polish items the project can close in future passes without holding this branch.

The minor inline items (URL form discrepancy on disclosure line 56, parser CRLF robustness, icon-mapping coupling comment) are recommended-but-not-blocking. Recommend a coordinated WebUI typecheck verification + the @campfirein/byterover-packages bump as the operational gating step before merge — both flagged in the PR description, neither introduced by this branch.
· branch: proj/analytics-system-tool-mode


For full details on how ByteRover handles your data, see:

https://byterover.dev/services/privacy
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (URL consistency, non-blocking): Two slightly different privacy URLs ship today:

Source URL
src/shared/constants/privacy.ts:1 https://www.byterover.dev/services/privacy (with www.)
This file (line 56) https://byterover.dev/services/privacy (no www.)

94b1cae did exactly the right thing by deleting the ANALYTICS_PRIVACY_URL duplicate and routing the WebUI through PRIVACY_POLICY_URL. The CLI/TUI consent prompt renders the markdown above, which embeds the apex form; the WebUI footer link uses the www. form (visually normalized at display via .replace(/^https?:\/\/(www\.)?/, '')).

If the apex serves a 301 to www. (common DNS setup), operationally identical and visually identical. If they diverge, a user copy-pasting the markdown URL into a browser lands at one canonical page while a click in the WebUI lands at another. Single-sourcing this — either by adding the https:// part programmatically or by aligning the markdown's URL to www. — closes the last bit of disclosure-surface drift.

Suggested change
https://byterover.dev/services/privacy
https://www.byterover.dev/services/privacy

this.cachedSections = sections
return sections
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: This handler closes the single ship-blocker that stood through 19 prior reviews. By exposing the canonical markdown over a transport event and routing the WebUI through it (94b1cae), the disclosure body now has exactly one source of truth — src/shared/assets/analytics-disclosure.md. The deleted ANALYTICS_DISCLOSURE_SECTIONS constant + the deleted ANALYTICS_PRIVACY_URL duplicate close the parallel-sources drift the 13th review predicted would happen on the next PM/legal copy round. Now PM/legal touches one file.

The handler shape is tight: dependency injection on loadDisclosure for tests, lazy-cached parse on first success (ed90d74), explicit throw instead of silently serving empty sections (1275ff0), error propagation that does NOT poison the cache (loader throw on call N+1 still re-attempts on call N+2 since the cache assignment is after the load succeeds). The test file at analytics-disclosure-handler.test.ts pins all five surfaces (registration, parsing, caching, empty-disclosure guard, loader-error propagation).

The 1aa060b robustness commit's icon-mapping fix (positional → label-keyed SECTION_ICONS with Info fallback) is the right defense: PM/legal can reorder sections without silently shifting icons. Combined with the substring-based "privacy" filter in disclosure-details.tsx:24, the rendering stays correct under section renames too.

minor parser observation (non-blocking): parse-analytics-disclosure.ts:19 splits on \n only. If the bundled markdown is ever committed with CRLF line endings (Windows editor + missing .gitattributes entry), the captured label and body lines would carry trailing \r characters — the H2_PATTERN's \s*$ regex would still match (regex \s matches \r), so the label captured by (.+?) is fine, but body lines pushed to currentBodyLines would have stray \rs that survive the body.trim() call (trim only touches leading/trailing whitespace on the whole joined block, not inner line ends). Not a bug today since the asset is unix-formatted, but worth one line in a .gitattributes to pin the asset as text eol=lf.


function isVisibleSection(section: AnalyticsDisclosureSection): boolean {
return !section.label.trim().toLowerCase().includes('privacy')
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise + minor observation: The robustness fix in 1aa060b is the right shape — substring 'privacy' match survives renames to "Privacy notice", "Privacy Policy & Terms", etc. without leaking the section into the icon grid (which would duplicate the existing footer link in analytics-panel.tsx:104-112).

non-blocking observation: This filter assumes the markdown will always have exactly one section whose label contains "privacy" — and that it should always be hidden from the grid. The current contract is fine, but worth a one-line code comment surfacing the assumption so a future "Privacy controls" section (a visible privacy panel, not a footer link) doesn't get silently filtered out. Something like:

// Hide the "Privacy policy" section: the footer link in AnalyticsPanel
// already exposes the URL, and showing it as a grid card would duplicate.

Same logic for the SECTION_ICONS mapping at lines 11-17 — adding a one-liner that label renames in the markdown require matching updates to the icon map (or the section gets the Info fallback) would close the coupling-discoverability gap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants