Skip to content

Proj/analytics system tool mode#726

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

Proj/analytics system tool mode#726
bao-byterover wants to merge 141 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
Comment thread src/server/templates/sections/analytics-disclosure.md Outdated
Comment thread src/server/core/domain/analytics/batch.ts
Comment thread src/shared/analytics/stored-record.ts
…ompleted

Bundles team_id alongside space_id (added in b94829c) so PMs can
slice the curate/query funnel by team as well as space.

Schemas: optional `team_id: z.string().min(1).max(64).optional()` on
both CurateRunCompletedSchema and QueryCompletedSchema. Same opaque-ID
shape, same independent-optional semantics — a project may have a
team without a space (mid-onboarding) and either / both / neither
field can be present.

AnalyticsHook: replaced the `getSpaceId` accessor with
`getIdentity: (projectPath) => Promise<{spaceId?, teamId?}>` so a
single config read serves both stamps at terminal-emit time. The
defensive `resolveIdentity` wrapper normalizes empty strings to
undefined per-field and swallows producer throws — a missing /
broken identity NEVER blocks a terminal emit.

Daemon: wires getIdentity through `projectStateLoader.getProjectConfig`,
returning {spaceId: config?.spaceId, teamId: config?.teamId}.

Tests:
- Schema: accepts populated team_id, both together, neither; rejects
  empty + over-64-char (per-field)
- Hook: stamps team_id alone (no space), space alone (no team), both,
  neither; normalizes empty strings; survives throw; stamps on
  failure-path emits

Validated end-to-end via real curate + query in vibing-zone (a
connected project with both spaceId + teamId):

  curate_run_completed | space_id=019e4505-... | team_id=019b8213-...
  query_completed      | space_id=019e4505-... | team_id=019b8213-...

rows landed in telemetry postgres with both fields populated.
Three findings from the Claude bot review.

#1 — Move `ProjectIdentity` + its docblock OUT of the gap between the
`AnalyticsHook` class docblock and the `class AnalyticsHook`
declaration. The previous layout left the class docblock orphaned —
attaching visually to `type ProjectIdentity` instead of the class.
Result: doc generators and IDE hover now resolve the class docblock
correctly.

#3 — Short-circuit `resolveIdentity` on `!this.isEnabled()`. Mirrors
the existing `readFrontmatterFields` precedent (lines 699-700). When
analytics is off the daemon no longer touches `projectStateLoader` on
every task termination just to discard the result. Added a unit test
that verifies getIdentity is NOT invoked when isEnabled() returns
false — locks the short-circuit so a future refactor can't quietly
re-introduce the wasted I/O.

#2 — Acknowledged the staleness window in the `getIdentity` docblock.
`projectStateLoader` only invalidates on the GET_PROJECT_CONFIG
transport event (agent-process startup); mid-session config rewrites
(`brv login`, `brv space switch`) keep returning the last-known-good
identity until the next invalidation. Funnel analytics accepts
last-known-good as the contract — a doc-line is enough. Anyone who
later wants this accessor for billing or audit attribution is now
explicitly warned to route through `shouldInvalidate`.

#4 was praise on the test matrix — no code change.

Full unit suite still green.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Claude finished @cuongdo-byterover's task in 3m 48s —— View job


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

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). The new ENG-3020 work bundles space_id + team_id onto curate_run_completed / query_completed via a clean getIdentity bundle accessor; PR #739's own review feedback was directly addressed in 7c247e0 (class-docblock placement, isEnabled short-circuit, staleness contract). Score held at 3/5 because the single ship-blocker from the prior nine reviews — lorem-ipsum disclosure body — is still in the tree, and ENG-3020 grows the disclosure surface that the eventual PM/legal copy needs to cover.

Todos

  • Identify new commits since the previous review (6 commits in ENG-3020 chain)
  • Re-check analytics-disclosure.md (9× prior ship-blocker)
  • Spot-check changes in this push (b94829c, 4229052, 525ff57, 7c247e0)
  • Re-verify prior unresolved findings
  • Post inline issues + summary

What's new since the last review (6 commits — all ENG-3020)

  • b94829c (ENG-3020) — Stamps optional space_id on curate_run_completed / query_completed schemas. New getSpaceId: (projectPath) => Promise<string | undefined> dep on AnalyticsHook, wired through projectStateLoader.getProjectConfig in brv-server.ts. Defensive resolveSpaceId wrapper swallows throws + normalizes empty strings; failure-path emits (onTaskError / onTaskCancelled) stamp the field too.
  • 4229052 (ENG-3020) — Type-narrowing on getSpaceId default.
  • 525ff57 (ENG-3020) — Bundles team_id alongside space_id. Schemas grow an optional team_id: z.string().min(1).max(64).optional(); accessor refactored from getSpaceId to getIdentity: (projectPath) => Promise<{spaceId?, teamId?}> so a single config read serves both stamps. Schema docblock spells out the independence semantics (mid-onboarding state may have team without space). End-to-end validated in a connected project — both fields land in telemetry postgres.
  • 7c247e0 (ENG-3020) — Direct response to PR Feat/eng 3020 #739 review: (1) ProjectIdentity type moved out of the gap between the class docblock and the class declaration so doc-generators / IDE hover resolve correctly; (2) resolveIdentity short-circuits on !isEnabled() mirroring readFrontmatterFields precedent — avoids a projectStateLoader read per task on disabled installs, pinned by a new "does not invoke getIdentity when analytics is disabled" test; (3) staleness contract documented on the getIdentity accessor — funnel analytics tolerates last-known-good across brv login / brv space switch mid-session, but the docblock explicitly warns NOT to reuse this accessor for billing / audit attribution.

New findings on this pass

  1. analytics-disclosure.md — ship-blocking, tenth flag. Unchanged. With ENG-3020 layered on top, the "What is collected" section now needs to enumerate space_id + team_id alongside the existing super-properties (device_id, cli_version, os, node_version, environment) — backend-resolvable team/space identifiers materially change the disclosure model. Inline →
  2. analytics-hook.ts:749-763 — praise + a question. The 7c247e0 cleanup is exactly the right shape; the staleness contract docblock is the kind of contract-as-code that prevents future misuse. Open question: does space_switched's eventual emit synchronously invalidate projectStateLoader so the next terminal emit sees the new identity, or do we rely on GET_PROJECT_CONFIG's natural-cadence invalidation? Worth confirming when the space_switched producer lands. Inline →
  3. curate-run-completed.ts:37-46 — schema + privacy observation, non-blocking. Both fields use z.string().min(1).max(64).optional(), matching the existing from_space_id / to_space_id on space_switched. Correctly absent from FORBIDDEN_FIELD_NAMES; not hashed. Defensible as opaque IDs, but make sure the eventual disclosure copy names them. Inline →
  4. analytics-hook.ts:438-441 — nit, non-blocking. Identity stamps straddle task_id / task_type in both buildCurateRunPayload and buildQueryCompletedPayload. Object-property order is cosmetic but harder to read; could fold into a small identityProps(identity) helper for consistency with projectPathHashOptional. Inline →

Prior findings — status

# File Finding Status
A analytics-disclosure.md Lorem-ipsum body ship-blocker, 10th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) unresolved
C http-analytics-sender.ts:62-69 No-deviceId path returns no reason, ticks retry cap toward terminal 'failed' instead of http_4xx-classifying unresolved
D analytics-hook.ts:88 <outside-project>/${basename(filePath)} leaks filename leaf unresolved
E analytics-handler.ts dispatch switch 48 cases scaling poorly vs. table-driven dispatch unresolved (non-blocking)
F event-names.ts:32 MCP_SESSION_START vs. MCP_SESSION_ENDED tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — comment justifying serial ordering would help unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race in track() — undocumented unresolved (non-blocking)
I migrate-handler.ts:132-140 classifyMigrateFailure brittle string-prefix match unresolved (non-blocking)
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT pairing relies on socket.io per-socket FIFO + raw curate result persists on live task.toolCalls[i].result 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 (moved to Privacy tab)
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes named one hyphen apart unresolved (non-blocking)
N onboarding.md:290-292 Verbal-script scope gap vs. canonical disclosure (super-properties undersold, cross-device alias omitted, "anytime" overpromises) unresolved (now compounds with ENG-3020's added identity stamps)
O onboarding.md:299-303 "Two promises" framing vs. code reality (track() writes JSONL unconditionally, only flush() gated on isEnabled) unresolved (non-blocking)
P onboarding.md:305-308 "Do not re-ask in future sessions" without a persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire-format change resolved by 3568d4b
R stored-record.ts:99-109 toWireEvent derivation: TZ-at-send-time offset + second-precision truncation resolved by 3568d4b

What's good on this push

  • Bundle accessor pattern. Replacing getSpaceId: (projectPath) => Promise<string | undefined> with getIdentity: (projectPath) => Promise<{spaceId?, teamId?}> is the right move — one config read serves both stamps at terminal-emit time, and the next identity field added (org_id, billing_team_id, etc.) doesn't add a fourth accessor parameter. The defensive resolveIdentity wrapper (normalize empty strings → undefined, swallow throws) makes the call site at buildCurateRunPayload / buildQueryCompletedPayload trivially safe.
  • Documented staleness contract. The 7-line docblock on getIdentity at analytics-hook.ts:222-229 names the cache-invalidation boundary, names the user actions that violate the implicit freshness assumption, AND explicitly warns NOT to reuse the accessor for billing / audit attribution. That last line in particular is the kind of contract-as-code that pays compound interest the day someone reaches for the accessor in a different context.
  • isEnabled() short-circuit. Mirroring readFrontmatterFields:709 makes the disabled-install path symmetric — neither I/O dance happens when analytics is off. The new "does not invoke getIdentity when analytics is disabled" unit test pins it.
  • Schema test discipline. The schema-level tests pin every cell of the matrix (both, team-only, space-only, neither, empty strings, throw) — the privacy fixture already walks .optional() fields, so additive schema changes never weaken the audit guarantees.
  • Test coverage on failure-path emits. onTaskError and onTaskCancelled both go through dispatchTerminalresolveIdentitybuildCurateRunPayload — identity stamps on failure rows, not just happy-path rows.

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 — if sessionKey ever upgrades to a bearer-equivalent secret, the analytics POST endpoint becomes an auth surface. Note in the backend contract.

Verdict

One ship-blocker, unchanged for the tenth review in a row. Replace analytics-disclosure.md with PM/legal-approved copy. ENG-3020 grows the disclosure surface (space_id / team_id join the named identifier list), so the PM/legal copy round should cross-check the enumerated field list against ALL_EVENT_SCHEMAS to keep the disclosure honest as the schema set grows. The (placeholder until M1.5) annotation cleanup (Finding B) and the onboarding-script gaps (N/O/P) should land in the same PM/legal copy round — they all hang off the same legal premise. Every other finding is recommended-but-not-blocking. The ENG-3020 work itself is well-engineered, with the staleness contract docblock and isEnabled short-circuit being especially well-considered closes on prior review feedback.
· branch: proj/analytics-system-tool-mode

Comment thread src/server/templates/sections/analytics-disclosure.md Outdated
Comment thread src/server/infra/process/analytics-hook.ts
Comment thread src/shared/analytics/events/curate-run-completed.ts
Comment thread src/server/infra/process/analytics-hook.ts
* feat: [ENG-3004] expose analytics opt-in flag as brv settings analytics.enabled

Surfaces the analytics opt-in via a new `analytics.enabled` boolean
descriptor. SettingsHandler acts as a facade: GET/SET/RESET/LIST for the
key route through GlobalConfigHandler instead of FileSettingsStore, so
the canonical storage in config.json, the device-id seeding race fix,
the analytics cache, and the abort-on-disable side-effect all stay
unchanged. The descriptor carries a new `storage: 'global-config'` flag;
the file store refuses to persist such keys and the validator pushes any
hand-edited override into the invalid bucket.

The disclosure prompt moves into `brv settings set analytics.enabled true`
with a `--yes` flag for CI / non-interactive runs (CLIError + non-zero
exit when no TTY and no --yes). The TUI settings page gains a
confirm-disclosure mode that renders the markdown inline on the
false-to-true toggle and waits for Enter / Esc.

Disclosure markdown moved from `src/server/templates/sections/` to
`src/shared/assets/` so the TUI can read it without crossing the
`tui` -> `server` import boundary. Both surfaces consume it via the new
canonical loader at `src/shared/utils/load-analytics-disclosure.ts`.
The legacy `brv analytics enable` command delegates to the extracted
disclosure helper at `src/oclif/lib/analytics-disclosure.ts` so the
command file can be safely deleted in a follow-up.

* fix: [ENG-3004] make TUI analytics-disclosure scroll in short terminals

The disclosure overlay rendered the full markdown as a single Text block,
which let wrapped long lines push the sticky `Enter / Esc` footer off
the visible area in short terminal windows. Now the body lives in a
sized viewport: each markdown line is its own `wrap="truncate-end"` Text
so source-to-visual row mapping is 1:1, the body height is clamped to
`terminalRows - 12` (defensive for the REPL's own bottom bar), and
overflow shows as yellow `↑ more above` / `↓ more below` indicators.
↑/↓ scroll by line; PgUp/PgDn (and `b` / `Space`) scroll by page.

* chore: [ENG-3007] delete the brv analytics command tree

The `brv analytics enable | disable | status` commands are folded into
the unified `brv settings` surface (`analytics.enabled` boolean facade
and `analytics.status` readonly-info snapshot, shipped in earlier
milestone tickets). The legacy command files and their test directory
are removed; `brv analytics <anything>` now exits with oclif's
"Command not found" message.

Lib re-exports survive the deletion so the M16.2 + M16.3 work keeps
working:
  - `src/oclif/lib/analytics-disclosure.ts` (already extracted earlier)
  - `src/oclif/lib/analytics-status-formatter.ts` (NEW thin re-export
    from the canonical home at `src/shared/utils/format-analytics-status.ts`,
    so the AC's "importable from src/oclif/lib/" surface is preserved
    while the TUI keeps consuming the same renderer via shared/).

Two replacement smoke tests confirm the oclif command path resolves
both analytics keys to the `settings:get` transport event:
  - test/commands/settings/analytics-enabled.test.ts
  - test/commands/settings/analytics-status.test.ts
Heavy lifting stays in the unit test suites that already cover the
facade routing, disclosure flow, and formatter parity.

The disclosure markdown content contract (5 required sections + privacy
URL) is rescued from the deleted enable.test.ts into
test/unit/shared/assets/analytics-disclosure-content.test.ts so a
future copy edit cannot silently break the section headers.

* fix: [ENG-3007] address PR #734 bot review

- Disclosure markdown: brv analytics disable -> brv settings set analytics.enabled false
- Tighten disclosure-content regex to require the new command form
- settings set: warn when --yes is passed for non-analytics keys
- settings set: JSON mode refuses interactive consent, returns requires_consent envelope
- analytics-disclosure: translate inquirer ExitPromptError to declined consent
- settings-page TUI: import ANALYTICS_ENABLED_KEY from shared/constants
- settings-page TUI: surface transport rejection on disclosure-confirm toggle
- settings-handler: facade-missing branches use code: 'misconfigured'
- settings-handler: explicit guard for non-boolean global-config RESET
- shared/transport: widen SettingsErrorDTO.code union with 'misconfigured'
- file-settings-store: drop trailing blank line
- rewrite historical doc-comment refs from brv analytics to brv settings
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Claude finished @bao-byterover's task in 6m 3s —— View job


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

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). The new ENG-3004 + ENG-3007 work (c6f7ccd) is a well-considered consolidation: the brv analytics enable | disable | status command tree is folded into the unified brv settings surface, the disclosure markdown is relocated to src/shared/assets/ behind a canonical loader (lifting the tui → server import-boundary constraint), the TUI gains a properly-scrollable disclosure overlay for short terminals, and the descriptor description for analytics.enabled now explicitly states "Local tracking is always on" — directly addressing the eighth-review trust-separation framing gap. Score held at 3/5 because the single ship-blocker from the prior ten reviews — lorem-ipsum disclosure body — is still in the tree.

Todos

  • Identify new commits since previous review (c6f7ccd — ENG-3004 + ENG-3007 command consolidation)
  • Re-check analytics-disclosure.md (10× prior ship-blocker)
  • Spot-check changes in this push (settings facade, TUI scroll viewport, disclosure relocation, JSON+--yes path)
  • Re-verify prior unresolved findings
  • Post inline issues + summary

What's new since the last review (1 substantive multi-commit squash)

  • c6f7ccdENG-3004 + ENG-3007: brv analytics command tree → brv settings consolidation. Three layered changes squashed into one PR-merge commit:
    1. analytics.enabled as a settings descriptor with storage: 'global-config' (settings.ts:197-205). SettingsHandler routes GET/SET/RESET/LIST for the key through a globalConfigHandler facade (settings-handler.ts:58-61) so canonical storage in config.json, the device-id seeding race fix, the analytics cache, and the abort-on-disable side-effect all stay unchanged. FileSettingsStore refuses to persist the key; SettingsValidator.partition pushes any hand-edited override into the invalid bucket with a precise reason string.
    2. Disclosure markdown relocated from src/server/templates/sections/src/shared/assets/ with a canonical loader at src/shared/utils/load-analytics-disclosure.ts. TUI no longer needs to cross the tui → server import boundary to render the disclosure inline. Both oclif (collectConsent) and TUI (confirm-disclosure mode) consume the same single source.
    3. TUI settings page gains a scrollable disclosure overlay for short terminals (settings-page.tsx:347-378). Body height clamps to terminalRows - 12, per-line wrap="truncate-end" preserves 1:1 source-to-visual mapping for the scroll bookkeeping, ↑/↓ scrolls by line, PgUp/PgDn/space/b scrolls by page (mirroring less).
    4. Legacy brv analytics enable | disable | status commands deleted (-526 lines from src/oclif/commands/analytics/, -694 lines from test/commands/analytics/). Lib re-exports at src/oclif/lib/analytics-disclosure.ts + src/oclif/lib/analytics-status-formatter.ts preserve the imports M16.2/M16.3 work depends on. Two replacement smoke tests confirm the oclif command path resolves both analytics keys via settings:get.
    5. Disclosure-content contract test preserved at test/unit/shared/assets/analytics-disclosure-content.test.ts — pins the five required section headers, requires the new disable command form (brv settings set analytics.enabled false), explicitly refuses the deleted form (brv analytics disable).
    6. PR Feat/eng 3007 #734 bot-review fixes: --yes warns when used for non-analytics keys, JSON mode refuses interactive consent (returns requires_consent envelope), ExitPromptError translates to declined consent, facade-missing branches use code: 'misconfigured'.

New findings on this pass

  1. analytics-disclosure.md — ship-blocking, eleventh flag. Same body, new home. Four formal consent surfaces all read the same lorem-ipsum text. Inline →

  2. settings/set.ts:157-162 — JSON+--yes disclosure-skip asymmetry, non-blocking. Text-mode --yes still logs the disclosure (collectConsent calls onLog(disclosureMarkdown) even when yesFlag is true), but JSON+--yes skips collectConsent entirely to keep stdout parseable. A CI script that hardcodes --yes --format json from the first call accepts without ever seeing the disclosure on any surface. Two ways to close the asymmetry (stderr stream, or disclosure field in the success envelope); neither blocking but worth recording as a follow-up. Inline →

  3. settings.ts:200 — praise (descriptor honesty). description: 'Send anonymous telemetry to ByteRover. Local tracking is always on.' directly surfaces the trust-separation reality that the eighth-review finding (O) flagged. Recommend reusing this sentence in the disclosure-md "What is collected" body when PM/legal replace the lorem ipsum. Inline →

  4. settings-page.tsx:347-378 — praise + minor nit (TUI scroll viewport). Per-line wrap="truncate-end" + terminalRows - 12 clamp + conditional overflow markers handle the short-terminal case cleanly. Minor nit on the React key shape (${clampedScroll}:${idx} rebuilds every row on every scroll tick — cosmetic). Inline →

  5. file-settings-store.ts:130-142 — internal-terminology error message, non-blocking. FileSettingsStore.reset() on a storage: 'global-config' key throws with "use the SettingsHandler facade" — internal implementation term in a user-reachable error message. Defense-in-depth check is correct; the wording deserves a softer caller-actionable phrasing. Same pattern in settings-validator.ts:152-158. Inline →

Prior findings — status

# File Finding Status
A analytics-disclosure.md Lorem-ipsum body ship-blocker, 11th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) user-facing copy resolvedanalytics/enable.ts + analytics/status.ts are deleted, so the (placeholder until M1.5) text no longer surfaces to users. The internal doc comment in privacy.ts:3 remains but is not user-facing
C http-analytics-sender.ts:62-69 No-deviceId path returns no reason, ticks retry cap toward terminal 'failed' unresolved
D analytics-hook.ts:88 <outside-project>/${basename(filePath)} leaks filename leaf unresolved
E analytics-handler.ts dispatch switch 48 cases scaling poorly unresolved (non-blocking)
F event-names.ts:32 MCP_SESSION_START vs. MCP_SESSION_ENDED tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — undocumented serial-ordering rationale unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race in track() — undocumented unresolved (non-blocking)
I migrate-handler.ts:132-140 classifyMigrateFailure brittle string-prefix match unresolved (non-blocking)
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT pairing relies on socket.io per-socket FIFO 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 (moved to Privacy tab earlier)
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes named one hyphen apart unresolved (non-blocking)
N onboarding.md:290-292 Verbal-script scope gap vs. canonical disclosure unresolved — onboarding.md changes on this push were command-rename only, not script-content updates
O onboarding.md:299-303 "Two promises" framing vs. code reality mitigated by descriptor description honesty (settings.ts:200) — onboarding doc itself unchanged in substance, but a user inspecting the descriptor before the agent flow sees the correct mental model first
P onboarding.md:305-308 "Do not re-ask in future sessions" without persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved earlier (3568d4b)
R stored-record.ts:99-109 toWireEvent derivation caveats resolved earlier (3568d4b)

What's good on this push

  • Command consolidation. Folding brv analytics enable | disable | status into brv settings set analytics.enabled + brv settings get analytics.status halves the surface area users have to learn AND keeps the consent-flow code in one path. Future analytics-adjacent settings (sampling rate, debug verbosity, etc.) get the same flag for free without spawning new top-level commands.
  • Disclosure asset relocation + canonical loader. Single source of truth at src/shared/assets/analytics-disclosure.md consumed by oclif (collectConsent), TUI (confirm-disclosure mode), and any future WebUI render — no boundary crossing required. Build pipeline copies the asset to dist/shared/assets/ so the runtime resolution lands correctly in both dev and prod.
  • Disclosure-content test discipline. test/unit/shared/assets/analytics-disclosure-content.test.ts:25-34 pins the five required section headers AND the new disable command name AND refuses references to the deleted command — a future copy edit that accidentally drops a section OR re-introduces brv analytics disable fails loudly. That's exactly the contract test you want guarding PM/legal copy.
  • storage: 'global-config' descriptor flag. Clean separation between settings persisted to settings.json (file store) vs. config.json (global-config handler). The flag is read at three points (FileSettingsStore.get/list/reset/set + SettingsValidator.partition/validate + SettingsHandler.SET/RESET/resolveCurrent) so a future second global-config-stored writable wires in mechanically.
  • --yes warn vs. error policy. set.ts:72-76 warns (not errors) when --yes is used for non-analytics keys — automation scripts don't break if someone wires the flag too broadly, but they get a visible signal. Right calibration.
  • TUI scroll viewport. Per-line wrap="truncate-end" + clamped viewport + conditional overflow markers handle the short-terminal failure mode without losing the sticky Enter/Esc footer. The body Box uses explicit flexShrink={1} so wrapped markdown lines can't push the footer off-screen.
  • ExitPromptError translation. analytics-disclosure.ts:87-95 translates Ctrl-C inside @inquirer/prompts to declined consent (matched by error.name, not instanceof, because the running @inquirer/core copy depends on which nested node_modules path resolved). That single eight-line block prevents a stack-trace exit on Ctrl-C — the kind of detail that's only caught when someone actually runs the command interactively.

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, eleventh review in a row. Replace analytics-disclosure.md with PM/legal-approved copy. The user-facing (placeholder until M1.5) annotation (Finding B) is naturally resolved by this push because the two command files that surfaced it were deleted. Finding O (the onboarding two-promises framing) is meaningfully mitigated by the new descriptor honesty at settings.ts:200 even though the onboarding doc itself wasn't reworded — recommend reusing the descriptor sentence verbatim in the disclosure-md body when PM/legal replace the lorem ipsum.

Every other finding is recommended-but-not-blocking. The ENG-3004 + ENG-3007 consolidation itself is well-engineered: cleaner surface, cleaner code paths, defensive guards in the right places, and a contract test that future copy edits can't silently break.
· branch: proj/analytics-system-tool-mode

Comment thread src/shared/assets/analytics-disclosure.md
Comment thread src/oclif/commands/settings/set.ts
Comment thread src/server/core/domain/entities/settings.ts
Comment thread src/tui/features/settings/components/settings-page.tsx
Comment thread src/server/infra/storage/file-settings-store.ts
* feat: [ENG-3019] rotate device_id on logout, account-switch, and refresh-failure

Rotate the global device_id after user-initiated identity transitions so
the machine's analytics identity does not survive a session boundary.
Mirrors PostHog/Mixpanel/Amplitude/Segment reset() semantics.

Triggers:
- Explicit brv logout when previously authenticated
- brv login account switch (userA to userB with no logout in between)
- Refresh-token exchange failure (treated as full sign-out: clear token,
  emit auth_logout {failure_kind:'refresh_failed'}, broadcast
  STATE_CHANGED, rotate)

Does NOT trigger on:
- Logout while already anonymous (or with an expired stored token)
- Polling-observed token expiry without an attempted refresh
- Same-user re-login

Rotation runs AFTER the auth_login / auth_logout emit so the transition
event still carries the OLD device_id, preserving attribution to the
departing user's history. identity-resolver re-reads per call, so the
next track() picks up the new id automatically.

New IGlobalConfigRotator interface; GlobalConfigHandler implements it.
rotateDeviceId() chains through the same writeChain as setAnalytics to
serialize against concurrent flag toggles. AuthHandler depends on the
narrow interface (optional, mirroring the existing optional
analyticsClient pattern).

* fix: [ENG-3019] address PR #740 bot review

Narrow the setupRefresh sign-out catch to refreshToken() ONLY. A failure
there means the auth server rejected the refresh (definitive sign-out).
Failures AFTER refresh succeeded (user fetch 5xx, save disk error) are
post-refresh APPLICATION failures and must not burn a device_id rotation
or be attributed as refresh_failed in the analytics funnel: return
{success:false} silently instead. Adds a processLog breadcrumb on each
branch so refresh sign-outs leave at least one daemon-log line.

Other PR-bot feedback addressed:
- Move IGlobalConfigRotator from core/interfaces/state/ to
  core/interfaces/storage/ (rotator writes to disk via the config
  store, not in-memory state).
- Loosen failure_kind assertion regex from [a-z_] to [a-z0-9_] so
  future tags like oauth_v2_flow or http_429 are not rejected at the
  test boundary.
- Pin UUID v4 shape on the rotation test so a regression that swaps
  randomUUID for a non-UUID source fails loudly.
- Document the safeLoadToken trade-off (returning undefined on a
  transient token-store error silently skips one rotation; the
  alternative would discard a freshly-exchanged OAuth token).
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

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


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

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). The new ENG-3019 work (6b7ee29) is well-designed in isolation — narrow IGlobalConfigRotator interface, careful failure-mode handling, well-tested timing on rotation triggers. Score held at 3/5 because the single ship-blocker from the prior eleven reviews — lorem-ipsum disclosure body — is still in the tree, and ENG-3019 is the second consecutive milestone to add a privacy-relevant detail (device_id rotation) that the eventual PM/legal copy needs to surface.

Todos

  • Identify new commits since the previous review (6b7ee29 — ENG-3019 device_id rotation on identity transitions)
  • Re-check analytics-disclosure.md (11× prior ship-blocker)
  • Spot-check ENG-3019 changes (rotator interface, AuthHandler wiring, GlobalConfigHandler.rotateDeviceId, post-refresh-failure narrowing)
  • Re-verify prior unresolved findings
  • Post inline issues + summary

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

  • 6b7ee29 (ENG-3019) — Device-id rotation on user-initiated identity transitions. Mirrors PostHog/Mixpanel/Amplitude/Segment reset() semantics. Rotates on: explicit brv logout when previously authenticated, login account-switch (userA → userB with no logout between), and refresh-token exchange failure (treated as full sign-out: clear token + emit auth_logout {failure_kind:'refresh_failed'} + broadcast STATE_CHANGED + rotate). Does NOT rotate on: logout while already anonymous, polling-observed token expiry without an attempted refresh, same-user re-login. Rotation runs AFTER the auth_login/auth_logout emit so the transition row carries the OLD device_id, preserving attribution to the departing user's history. New IGlobalConfigRotator interface; GlobalConfigHandler implements it; AuthHandler depends on the narrow interface (optional, mirroring the existing analyticsClient pattern). 596 lines of test coverage including narrow sign-out semantics on refreshToken()-only catch, UUID v4 shape pin, snake_case failure_kind discipline ([a-z0-9_] tightening).

New findings on this pass

  1. analytics-disclosure.md — ship-blocking, twelfth flag. ENG-3019 adds a privacy-positive feature the disclosure should highlight ("How we limit linkage: device_id retires on logout / account-switch / refresh-failure"). Combined with ENG-3020's space_id + team_id stamps, "What is collected" needs to be reworked when PM/legal land. Inline →

  2. auth-handler.ts:444-453 (rotation-vs-identity-resolver TOCTOU) — non-blocking correctness observation. The docblock claim "Rotate AFTER the emit so this auth_logout row still carries the OLD device_id" rests on implicit microtask-ordering between trackAsync's identityResolver.resolve() (which awaits globalConfigStore.read()) and safeRotateDeviceId()'s globalConfigStore.write(). The writeChain on GlobalConfigHandler serializes setAnalytics vs. rotateDeviceId but does NOT serialize against trackAsync's independent read(). In practice the resolver's read is one microtask earlier and lands first under V8 + libuv FIFO, so the OLD device_id stamps correctly today. But a future refactor that adds an await to identityResolver.resolve() could silently invert the ordering. Two clean fixes: (a) capture device_id synchronously in track() via a getCachedDeviceId() mirror of getCachedAnalytics(), or (b) await the M4.5 pendingTracks barrier before rotating. Existing tests pin that rotation occurred but not which device_id the auth_logout row carried — a targeted resolver-stubbed test would catch the inversion. Inline →

  3. auth-handler.ts:490-523 (narrowed setupRefresh sign-out) — praise. The narrowed catch around refreshToken() only is exactly right: post-refresh app failures (getCurrentUser, tokenStore.save) no longer burn a device_id rotation or attribute as refresh_failed in the funnel. Per-step .catch(processLog) wrappers on each sign-out step keep the cascade going while leaving forensic breadcrumbs. The if (token.isValid()) guard on rotation prevents claiming the device for an already-expired pre-refresh identity. One nit: the explicit STATE_CHANGED broadcast at line 521 overlaps the setupExternalAuthSync-driven one — worth a one-line comment so a future maintainer doesn't delete the deliberate redundancy. Inline →

  4. i-global-config-rotator.ts — praise + one docblock nit. The narrow one-method interface is the right shape (lets AuthHandler depend on a tiny boundary, lets test suite stand up a makeRotatorStub, lets future rotation triggers reuse the contract). Placement under core/interfaces/storage/ is correct per PR Feat/eng 3019 #740 bot-review feedback. Worth a one-line note that rotation is deliberately NOT idempotent — two consecutive calls produce two different device_ids, which is the right semantic here but may surprise a future consumer expecting a "reset to canonical state" idempotent shape. Inline →

Prior findings — status

# File Finding Status
A analytics-disclosure.md Lorem-ipsum body ship-blocker, 12th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) user-facing copy resolved (ENG-3007 deleted the command files that surfaced it); internal doc comment remains non-user-facing
C http-analytics-sender.ts:62-69 No-deviceId path returns no reason, ticks retry cap toward terminal 'failed' unresolved (non-blocking)
D analytics-hook.ts:88 <outside-project>/${basename(filePath)} leaks filename leaf unresolved (non-blocking)
E analytics-handler.ts dispatch switch 48 cases scaling poorly vs. table-driven dispatch unresolved (non-blocking)
F event-names.ts:32 MCP_SESSION_START vs. MCP_SESSION_ENDED tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — comment justifying serial ordering would help unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race in track() — undocumented unresolved (non-blocking)
I migrate-handler.ts:132-140 classifyMigrateFailure brittle string-prefix match unresolved (non-blocking)
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT pairing relies on socket.io per-socket FIFO + raw curate result persists on live task.toolCalls[i].result 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 (moved to Privacy tab)
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes named one hyphen apart unresolved (non-blocking)
N onboarding.md:290-292 Verbal-script scope gap vs. canonical disclosure unresolved (compounds with ENG-3019 device-id rotation + ENG-3020 space_id/team_id stamps)
O onboarding.md:299-303 "Two promises" framing vs. code reality mitigated by settings.ts:200 descriptor honesty
P onboarding.md:305-308 "Do not re-ask in future sessions" without persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved by 3568d4b
R stored-record.ts:99-109 toWireEvent derivation caveats resolved by 3568d4b

What's good on this push

  • Narrow IGlobalConfigRotator interface. One method, one purpose. AuthHandler does not need (and should not depend on) the full GlobalConfigHandler surface. Future rotation triggers wire in by depending on this interface, not a god-object.
  • Failure-mode taxonomy. The trigger / non-trigger matrix in the commit message is exhaustive (covers expired-token logout, polling-observed expiry, same-user re-login as deliberately excluded cases). Every excluded case has a safeLoadToken + isValid() check enforcing it.
  • Narrowed setupRefresh sign-out. Wrapping only refreshToken() in the sign-out branch means refresh_failed in the funnel maps precisely to auth-server rejection, not to local app failures. The kind of correctness work that pays compound interest when an SRE later tries to investigate "why are our refresh-failure rates spiking?"
  • safeLoadToken trade-off documentation. The docblock at auth-handler.ts:264-273 names the trade-off (transient token-store read failure → caller treats prior identity as absent → silently skips one rotation) and the alternative (rejecting the RPC could discard a freshly-exchanged OAuth token). That's the right framing — the silent-skip is a UX-conscious choice, not a bug.
  • UUID v4 shape pin in the rotation test (global-config-handler.test.ts:394-396). A regression that swaps randomUUID() for Date.now().toString() or any non-UUID source now fails loudly at the test boundary, not silently in the warehouse.
  • failure_kind regex tightening to [a-z0-9_] so future tags like oauth_v2_flow or http_429 are not rejected at the test boundary, while still forbidding whitespace, capital letters, and symbols that would catch a developer accidentally passing error.message.
  • Loose-bind via globalConfigRotator? optional dep. Matches the existing analyticsClient? optional pattern — existing test harnesses don't need to thread the rotator through. Construction stays additive.
  • Emit-then-rotate ordering. Mod the TOCTOU finding above, the intent is right: the transition row should carry the OLD device_id so the departing user's history is keyed correctly.

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 — if sessionKey ever upgrades to a bearer-equivalent secret, the analytics POST endpoint becomes an auth surface. Note in the backend contract.

Verdict

One ship-blocker, unchanged for the twelfth review in a row. Replace analytics-disclosure.md with PM/legal-approved copy. ENG-3019 + ENG-3020 between them have grown the disclosure surface (device_id rotation behavior, space_id/team_id stamps) — the PM/legal copy round should cross-check the enumerated field list against ALL_EVENT_SCHEMAS AND surface the rotation behavior as a privacy-positive feature in the "Cross-device alias" section. Every other finding is recommended-but-not-blocking. The ENG-3019 work itself is well-engineered: narrow interface, careful failure-mode taxonomy, well-tested, addresses prior bot-review feedback (UUID v4 pin, snake_case regex relaxation, narrowed setupRefresh sign-out). The TOCTOU observation on rotation-vs-resolver timing is a "docblock claims a guarantee the code doesn't quite enforce" item — works today, worth tightening before the next refactor touches the resolver path.
· branch: proj/analytics-system-tool-mode

Comment thread src/shared/assets/analytics-disclosure.md
Comment thread src/server/infra/transport/handlers/auth-handler.ts
Comment thread src/server/infra/transport/handlers/auth-handler.ts
Comment thread src/server/core/interfaces/storage/i-global-config-rotator.ts
…acy tab (#742)

Default detailsOpen to true so the "What data will be collected?" panel is
expanded on first render. Helps the user see the disclosure without having
to click to reveal it, which matches the spirit of an opt-in privacy
surface.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Claude finished @bao-byterover's task in 2m 56s —— View job


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

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). The new ENG-2621 change (5d96619) is a one-line UX nudge in the right direction (default-open Privacy disclosure panel matches the spirit of an opt-in surface), but lands the lorem-ipsum disclosure as the default landing state of the WebUI Privacy tab rather than tucked behind a click. Score held at 3/5 because the single ship-blocker from the prior twelve reviews — lorem-ipsum disclosure body — is still in the tree, and ENG-2621 escalates its reach by removing the "Show details" gating click.

Todos

  • Identify new commits since the previous (12th) review (5d96619 — ENG-2621 one-line detailsOpen default flip)
  • Re-check analytics-disclosure.md (12× prior ship-blocker — still unchanged)
  • Spot-check changes in this push + WebUI panel surfaces
  • Re-verify prior unresolved findings + cross-check WebUI disclosure constants
  • Post inline issues + summary

What's new since the twelfth review (1 commit)

  • 5d96619 (ENG-2621) — detailsOpen default flipped from falsetrue in AnalyticsPanel (src/webui/features/analytics/components/analytics-panel.tsx:21). The "What data will be collected?" collapsible panel now starts expanded on every render of the Privacy tab. Single-line change, no test churn, no API surface change. UX intent is right for a default-off opt-in surface.

New findings on this pass

  1. analytics-panel.tsx:21 — ship-blocking escalation, thirteenth flag. Defaulting the disclosure panel open is correct in spirit, but the body content rendered into that panel is still lorem ipsum (src/webui/features/analytics/constants.ts:13-39, every section). Before this commit, lorem-ipsum lived behind a "Show details" click in the WebUI; after this commit, every user landing on the Privacy tab sees it without interaction. Inline →

  2. webui/features/analytics/constants.ts:11 — privacy URL inconsistency, non-blocking. Two different canonical privacy URLs ship in the codebase today: shared/constants/privacy.ts:6https://byterover.dev/privacy, but the WebUI panel uses https://docs.byterover.dev/privacy. If both DNS-resolve to the same legal page, harmless; if docs.* serves documentation rather than the canonical legal privacy policy, the consent surfaces point at different pages. Recommend hoisting one canonical URL from shared/constants/privacy.ts and importing it into the WebUI constants so the two surfaces stay in lockstep. Inline →

  3. webui/features/analytics/constants.ts:13-39 — disclosure-body duplication, non-blocking. Disclosure copy now lives in TWO sources of truth — src/shared/assets/analytics-disclosure.md (oclif + TUI) and ANALYTICS_DISCLOSURE_SECTIONS (WebUI). The disclosure-content test pins only the markdown; a future edit that updates one source and forgets the other silently drifts. The lorem-ipsum-replacement round is the moment of guaranteed divergence — recommend single-source via a build-time markdown loader, or at least a cross-source test. Inline →

Prior findings — status

# File Finding Status
A analytics-disclosure.md + webui/features/analytics/constants.ts:13-39 Lorem-ipsum body ship-blocker, 13th flag — surface count grew from 4 to 5 (default-open WebUI Privacy panel)
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) user-facing copy resolved (ENG-3007 deleted the command files that surfaced it); now compounded by URL inconsistency vs. WebUI
C http-analytics-sender.ts:62-69 No-deviceId path returns no reason, ticks retry cap toward terminal 'failed' unresolved (non-blocking)
D analytics-hook.ts:88 <outside-project>/${basename(filePath)} leaks filename leaf unresolved (non-blocking)
E analytics-handler.ts dispatch switch 48 cases scaling poorly vs. table-driven dispatch unresolved (non-blocking)
F event-names.ts:32 MCP_SESSION_START vs. MCP_SESSION_ENDED tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — comment justifying serial ordering would help unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race in track() — undocumented unresolved (non-blocking)
I migrate-handler.ts:132-140 classifyMigrateFailure brittle string-prefix match unresolved (non-blocking)
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT pairing relies on socket.io per-socket FIFO 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 (moved to Privacy tab)
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes named one hyphen apart unresolved (non-blocking)
N onboarding.md:290-292 Verbal-script scope gap vs. canonical disclosure unresolved (still bundled into the disclosure-body fix round)
O onboarding.md:299-303 "Two promises" framing vs. code reality mitigated by settings.ts:200 descriptor honesty
P onboarding.md:305-308 "Do not re-ask in future sessions" without persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved by 3568d4b
R stored-record.ts:99-109 toWireEvent derivation caveats resolved by 3568d4b
S auth-handler.ts:453 Rotate-after-emit TOCTOU on device_id (microtask ordering) unresolved from 12th pass (non-blocking)
T i-global-config-rotator.ts:19 Non-idempotent contract undocumented unresolved from 12th pass (non-blocking)

What's good in this push

  • Right UX call. Default-open disclosure on the Privacy tab is the correct privacy-surface posture. A Privacy page that hides its disclosure behind a click is contradictory; this fixes that contradiction at zero engineering cost.
  • Minimal change footprint. One-line constant flip, no test churn, no API or schema impact. The kind of focused commit that lands cleanly into the long-running branch without re-opening any prior milestones.

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 — if sessionKey ever upgrades to a bearer-equivalent secret, the analytics POST endpoint becomes an auth surface. Note in the backend contract.

Verdict

One ship-blocker, unchanged for the thirteenth review in a row, with growing exposure. 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. Ideally, consolidate to one source via a build-time markdown loader so the lorem-ipsum-replacement round (and every future copy edit) touches one file rather than two. The privacy URL inconsistency between shared/constants/privacy.ts and the WebUI constants should be resolved in the same round.

Every other finding is recommended-but-not-blocking. The ENG-2621 work itself is a correct one-line UX fix — its only side effect is to make the still-unresolved disclosure-body blocker more visible, which arguably accelerates rather than impedes the path to merge.
· branch: proj/analytics-system-tool-mode

const {data, error, isError, isLoading, refetch} = useGetGlobalConfig()
const setAnalytics = useSetAnalytics()
const [pendingIntent, setPendingIntent] = useState<'disable' | 'enable' | undefined>()
const [detailsOpen, setDetailsOpen] = useState(true)
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 (escalates the ship-blocker — thirteenth flag): Defaulting detailsOpen to true is the right UX call for an opt-in privacy surface — the whole point of the Privacy tab is to make data-handling transparent rather than tucked behind a click. But this lands the lorem-ipsum disclosure as the default landing state of the WebUI Privacy tab, rather than something the user has to click "Show details" to reach.

The disclosure-body content for the WebUI lives in src/webui/features/analytics/constants.ts:13-39 and every section's body field is literal lorem ipsum:

body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',

…repeated for WHAT IS COLLECTED, WHICH SURFACES ARE TRACKED, WHERE IT GOES, CROSS-DEVICE ALIAS, and HOW TO DISABLE. Before this commit, a user on the Privacy tab saw a clean toggle + a "Show details" button — clicking it would reveal lorem ipsum. After this commit, every user landing on the tab sees lorem ipsum without any interaction.

This commit's spirit is right (a privacy page should not hide its disclosure), but the precondition for that spirit is real disclosure copy. The lorem-ipsum prior reviews flagged 12 times now affects two distinct surfaces that need fixing in lockstep:

  1. src/shared/assets/analytics-disclosure.md (the canonical markdown, consumed by oclif brv settings set analytics.enabled true + TUI confirm-disclosure overlay + agent-driven --yes flow).
  2. src/webui/features/analytics/constants.ts:13-39 (the structured grid in the Privacy panel, consumed by DisclosureDetails / AnalyticsPanel).

They are not sharing source today — the markdown is parsed by oclif/TUI and the constants array is rendered by React. So replacement requires PM/legal to provide the body for both formats. Keep the section labels stable (load-bearing for analytics-disclosure-content.test.ts).

Fix this →

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 (privacy URL inconsistency, non-blocking but worth catching before the lorem-ipsum replacement round): Two different canonical privacy URLs now ship in the codebase:

Source URL Surface
src/shared/constants/privacy.ts:6 https://byterover.dev/privacy (referenced by analytics-disclosure.md:36)
src/webui/features/analytics/constants.ts:11 (this line) https://docs.byterover.dev/privacy WebUI Privacy panel link at analytics-panel.tsx:104-112

The WebUI panel's "docs.byterover.dev/privacy" link target diverges from the canonical disclosure-md's byterover.dev/privacy. A user reading the disclosure markdown via brv settings set analytics.enabled true reaches one URL; a user clicking the WebUI Privacy panel's link reaches a different one. If both resolve to the same legal page on the backend's DNS, it's invisible — but if they're separate routes (a docs.byterover.dev host typically serves documentation, not the canonical legal privacy policy), the consent surfaces are pointing at different pages.

When PM/legal land the disclosure copy round, the right resolution is one canonical URL (recommend hoisting from shared/constants/privacy.ts and importing here, so the two surfaces stay in lockstep). Today, fixing one and forgetting the other silently produces drift no test catches.

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

(or — re-export under the WebUI-local name if the renaming would churn callers).

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 (observed during the thirteenth review — code-quality / drift-risk, non-blocking): Disclosure copy is now duplicated across two sources of truth:

  1. src/shared/assets/analytics-disclosure.md — markdown body parsed by oclif collectConsent (src/oclif/lib/analytics-disclosure.ts:71) and the TUI scroll viewport (src/tui/features/settings/components/settings-page.tsx:347-378).
  2. ANALYTICS_DISCLOSURE_SECTIONS here — structured object array consumed by <DisclosureDetails> in the WebUI Privacy panel.

When PM/legal land approved copy, replacement has to happen in both places, with no test pinning them in sync. The disclosure-content test at test/unit/shared/assets/analytics-disclosure-content.test.ts:25-34 checks only the markdown — a future edit that updates the markdown but forgets the WebUI constants (or vice versa) silently produces drift. The lorem-ipsum body content itself happens to be identical across all five sections here, which obscures the drift risk until the replacement happens.

Two practical resolutions, in order of preference:

  1. Single source. Parse analytics-disclosure.md at build time (or via a vite-side text loader) into the structured shape <DisclosureDetails> consumes. Section headers in the markdown become the labels; section bodies become the body field. Closes the drift hazard architecturally — one edit, one test target.
  2. Cross-source test. Extend analytics-disclosure-content.test.ts to verify that every section's body in this array matches the corresponding section in the markdown (after some canonical normalization). Lighter but keeps two-source maintenance burden.

The icon mapping (Database, Eye, Server, Link2, PowerOff) and the WHAT-IS-COLLECTED-style upper-case labels are WebUI-specific affordances, so option (1) would need a section→icon map living next to the loader. The section body content is the part that drifts; the visual scaffolding is fine to keep here.

Non-blocking on the merge premise, but worth flagging now since the lorem-ipsum-replacement round is the one moment where the two sources are guaranteed to diverge if you forget to touch both.

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.

4 participants