Flow drops from five steps to three: role + use_case merge into a
single About-you screen (one Skip covers both; Continue stamps skip
markers on whichever group was left unanswered), and the source
question leaves onboarding entirely.
Source is now collected only by the workspace source-backfill prompt,
which additionally waits until agents/squads have completed at least
SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES (3) issues in the workspace —
attribution is asked after Multica has visibly delivered value, not
before. The count rides a limit:1 issues query keyed under
issueKeys.all so realtime invalidations keep it fresh, enabled only
for users who still owe an answer.
Server: questionnaire complete() narrows to role + use_case so the
funnel step doesn't stall on the now-deferred source; a new
metrics-only onboarding_source_submitted event (+ Prometheus counter)
tracks the backfill prompt's answer/decline transition once per user.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Agents were writing runtime-local paths into deliverables as clickable
links (`[screenshot](/Users/agent/work/shot.png)`). Two root causes, both
fixed here.
A. The brief never stated the delivery contract. Add an always-on delivery
invariant (outside writeOutput's kind switch, so no task kind can inherit
none) plus a per-surface file-delivery line for each of the five surfaces.
Chat splits into two: `attachment upload` works only on web/mobile chat,
never on an IM channel, so ChatChannelType is now threaded into
TaskContextForEnv.
The claim path only ever looked up Slack bindings, so a Feishu session
reported as a web chat and got upload guidance for a channel that cannot
carry attachments. Probe every channel type. The chat policy is two
independent layers and stays that way: delivery keys off "is there a
channel at all"; the `chat history` / `chat thread` commands stay
Slack-only because both endpoints are hardwired to h.SlackHistory and
there is no Feishu reader — ChatInThread only selects between those two
commands, so it stays Slack-only too.
Add a CLI hard-fail lint on `issue comment add` / `issue create` /
`issue update` as the enforcement backstop. Scoped narrowly, since a false
positive blocks a real deliverable: agent task context only (a human's PAT
run is untouched), real CommonMark link/image/autolink destinations only
via goldmark (a path in a code span or fence — how an agent quotes a path
it is discussing — is structurally invisible), and three high-confidence
signals only (`file://`, inside the workdir, or an existing local file).
A bare `/foo` is a valid origin-relative URI and is deliberately allowed.
`issue update` has no --attachment flag, so its hint redirects to
`comment add` rather than naming an argument it rejects.
B. Desktop presented the resulting router 404 as an unknown crash. 8 of 18
desktop_route_error reports were users clicking such a link and being told
the app broke and to file a bug. Split the 404 into a first-class Not Found
view: no crash framing, no Report error. Its recovery entry comes from the
tab store's active workspace, never from the failed pathname — deriving a
slug from `/Users/me/shot.png` yields "Users" and a button to `/Users/issues`,
a second 404.
Also add a will-navigate trusted-origin guard via the shared loadRenderer
(main + issue windows). This is origin hardening only, NOT the mechanism for
in-app links: client-side routing never fires will-navigate, so app paths
never reach it. Issue windows need no 404 work — their router only accepts
paths validated by parseIssueWindowPath and they do not listen for
multica:navigate, so a bad path cannot reach them.
Server-side completion observation is metric/log only and never blocks: it
is lexical (`file://` + task work_dir prefix) because the server cannot stat
the daemon's filesystem, and the metric label is a closed enum so no path or
reply text reaches Prometheus.
Verified: pnpm typecheck/lint/test (3582 tests), go vet, full Go suite
including new claim-path integration tests. cmd/multica was verified outside
the daemon workdir — inside one, 93 of its tests fail identically on
origin/main because the suite walks up and finds the runtime's own task marker.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP
(`grok --no-auto-update agent --always-approve stdio`), reusing
hermesClient like traecli and kimi. Includes daemon discovery,
protocol_family migration 174, model discovery, MCP passthrough,
thinking effort, frontend branding, and product docs.
Follows xAI's documented headless ACP flow: after `initialize`, read the
advertised `authMethods` and send `authenticate` (preferring xai.api_key
when XAI_API_KEY is set, else the cached login token) before any session
operation — a real, logged-in CLI rejects session/new and session/load
without it. Model discovery performs the same handshake so it returns the
live catalog instead of the static fallback.
`--no-auto-update` is passed as a global flag (and kept daemon-owned in
the blocked custom-arg set) so a background update check can't stall an
unattended ACP task. Thinking effort uses the current `--effort` flag,
and the minimum grok version is 0.2.89 (ACP + authenticate + session/load
+ session/set_model + MCP + --effort).
Closes#2895
* feat(analytics): anonymous self-host onboarding source beacon (MUL-3708)
Production self-host servers now report the anonymous onboarding "how did
you hear about us" channel to Multica's public write-only ingest, so the
self-host source distribution becomes visible alongside official cloud.
Official cloud keeps its existing PostHog capture unchanged; this is a
submit-time beacon, not a background telemetry pipeline.
- server/internal/sourcebeacon: ShouldSend gate (production + non-local +
non-*.multica.ai app host, fail-closed — judged by the app/frontend host,
not the backend URL, which official often leaves unset), per-instance
salted hashing, deterministic event uuid, fire-and-forget sender.
- POST /api/telemetry/self-host-source: public, write-only, per-IP
rate-limited, 4 KiB body cap, channel allowlist, strict unknown-field
rejection. Lands in PostHog as self_host_source_channel with a
deterministic uuid (best-effort dedup), $process_person_profile=false,
and deployment=self_host — a distinct event name so it never pollutes the
official onboarding funnel.
- Hook in PatchOnboarding fires once when the source is first set; never
blocks onboarding. Only channel enum(s) + two per-instance hashes leave
the box — never user_id/email/name/workspace/org/domain/role/use_case/the
source_other free-text/IP.
- migration 128: system_settings singleton holding instance_salt.
- frontend: self-host-only anonymous-collection notice on the source step,
gated by a new /api/config self_host_source_notice flag (en/zh-Hans/ko/ja).
- analytics.Event gains an optional top-level uuid; docs/analytics.md,
SELF_HOSTING.md and .env.example document exactly what is/isn't sent and
how to disable it (ANALYTICS_DISABLED). Also fixes the long-standing
team_size→source drift in docs/analytics.md.
Verified locally: go build/vet, go test (sourcebeacon, analytics, handler),
pnpm typecheck (all packages), locale parity (157), step-source (6) + core
config/schema (69) vitest, lint (0 errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(analytics): wire self-host source beacon through metrics, guard nil pool (MUL-3708)
Addresses Howard CI blockers on #4691 (no product-direction change):
- loadInstanceSalt returns "" on nil pool; salt is only loaded when
ShouldSendFromEnv() is true, via a bounded (5s) context — restores the
"router constructible without a DB" invariant (nil-pool routing tests).
- Add multica_self_host_source_channel_total counter (by source) + an
IncForEvent case, so every analytics event is paired with a Prometheus
counter. NormalizeSourceChannel reuses sourcebeacon allowlist (no 3rd copy).
- Beacon handler now builds the event via the analytics.SelfHostSourceChannel
helper and ships it through obsmetrics.RecordEvent (no naked Capture); not
IsMetricsOnly, so it still reaches PostHog.
- Prime the new family in the registry-families test.
Verified: go build/vet, go test ./internal/metrics ./internal/sourcebeacon
./internal/handler ./cmd/server (incl. the 3 named blockers + registry +
record-event-helper lints) all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949)
PR3 of the Grafana board metrics split (parent MUL-2328).
Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics
collector covering the activation/community/commercial funnels, and binds
every PostHog event emission to a matching metric increment so the two sides
cannot drift.
Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*,
cloud_waitlist_joined.
Content: issue_created, chat_message_sent, agent_created, squad_created,
autopilot_created, issue_executed.
Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram,
daemon_ws_message_received_total.
Autopilot: autopilot_run_started/terminal/skipped.
Webhook/GitHub: webhook_delivery_total, github_event_received_total,
github_pr_review_total, github_pr_merge_seconds histogram.
CloudRuntime: cloudruntime_request_total + duration histogram, wired through
a small RequestRecorder interface so the cloudruntime package stays decoupled
from metrics.
Commercial: feedback_submitted, contact_sales_submitted.
The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog
event AND increments the matching counter via IncForEvent dispatch, reading
labels from the analytics event Properties. Every existing
h.Analytics.Capture(analytics.X(...)) call site has been migrated to the
helper across handler/, service/, and cmd/server/runtime_sweeper.go.
Lint enforcement (server/internal/metrics/business_pairing_test.go):
- TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in
analytics/events.go either dispatches via IncForEvent or is in the
taskMetricEvents allow-list (PR2 typed RecordTask* methods).
- TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/
service/cmd-server for direct Analytics.Capture(...) calls — only
service/task.go's captureTaskEvent helper is allow-listed.
- TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third
arg of every metrics.RecordEvent call is built from analytics.*.
Cardinality protection: all new label values pass through fixed allow-lists
in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'.
Refs:
- Spec MUL-2328 / MUL-2949.
- Builds on PR2 (MUL-2948) — collectors registered through the same
BusinessMetrics struct, no separate Registry.
- Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason
label via NormalizeFailureReason.
Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total
emission point (no review event handler exists yet — counter is defined,
TODO to wire up when /api/webhooks/github grows pull_request_review handling).
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949)
Addresses 张大彪's review on #3698:
1. signup_source: NormalizeSignupSource added to labels_pr3.go with a
fixed allow-list bucket (direct/google/twitter/linkedin/.../other).
Parses JSON cookie payload for utm_source/source/referrer fields,
strips URL schemes, maps well-known hostnames to channel buckets.
PostHog event still ships the raw cookie value for analytics; only
the Prometheus label is bucketed.
2. Filled the unknown/other label gaps:
- analytics.IssueCreated and analytics.ChatMessageSent now take a
platform parameter sourced from middleware.ClientMetadataFromContext
(X-Client-Platform header) at the handler. Autopilot-originated
issues stamp PlatformServer.
- analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback
reads req.Kind (default "general") so the picker selection lights up
the metric's kind label instead of long-term "other".
- analytics.ContactSalesSubmitted now takes a formSource (page /
onboarding / agents_page); CreateContactSales reads req.Source.
The metric reads ev.Properties["form_source"] so the analytics
CoreProperties.Source ("marketing_contact_sales") stays
backward-compat for PostHog dashboards.
3. analytics.OnboardingStarted helper added; server-side emission lives
in PatchOnboarding, fired exactly once per user on the first PATCH
that carries a non-empty questionnaire payload (firstTouch logic
compares prior bytes against {} / null). Frontend onboarding_started
keeps firing on page open; the server emission is what guarantees the
Prometheus counter exists so Grafana can be cross-checked against the
PostHog funnel without depending on the SDK roundtrip.
4. business_pairing_test.go tightened:
- TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at
function granularity (just captureTaskEvent in service/task.go), not
whole-file. Any future naked Capture in the same file fails CI.
- TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use
tracking inside the enclosing FuncDecl: when RecordEvent's third
arg is an *ast.Ident, the test walks the function body for the
assignment that defined it and confirms the RHS is an
analytics.<Helper>(...) call. Bare local idents that didn't
originate from analytics are now caught.
5. gofmt -w applied across the touched files; gofmt -l clean.
Tests: go test ./internal/metrics/... ./internal/analytics/... pass.
Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier
failures on origin/main are DB-environment-dependent and not regressions
from this change.
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): normalise onboarding_started platform label + regression test (MUL-2949)
Addresses 张大彪's last review nit:
- IncForEvent's EventOnboardingStarted case now wraps the platform
property with NormalizePlatform, matching every other platform-bearing
metric. A misbehaving frontend can no longer leak a raw X-Client-Platform
header value into the multica_onboarding_started_total{platform=...}
series.
- New labels_pr3_test.go covers every PR3 normalizer with both a happy-path
value and an unknown value, asserting the unknown collapses to the
documented fallback bucket. Includes a focused regression for
onboarding_started: emits one event with an attacker-shaped platform
string and asserts the metric only exposes web + unknown label values
(no raw header bleed).
- testutil.go gains a small GatherForTest helper so the regression test
can pull the typed MetricFamily map without re-implementing the
registry-walk dance.
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949)
Final review touch-ups before merge:
- IncForEvent's EventWorkspaceCreated case wraps source through
NormalizeTaskSource, matching the other source-bearing dispatches
(issue_created, agent_created, issue_executed). Closes the last raw
property leak in the dispatcher table.
- business_pairing_test.go inline docstrings now spell out the two
known limitations of the lint gate that 张大彪 / Eve flagged:
analyticsBackedIdents matches by ident NAME (not SSA def-use, so a
nested-scope shadow could pass) and isMetricsRecordEvent hard-codes
the import alias set. PR description carries a Follow-ups section
with the same two items so the work is visible after merge.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: 魏和尚 <agent+wei@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>