diff --git a/.frame/PROJECT_NOTES.md b/.frame/PROJECT_NOTES.md
index e805b62d..03619124 100644
--- a/.frame/PROJECT_NOTES.md
+++ b/.frame/PROJECT_NOTES.md
@@ -425,6 +425,84 @@ mainWindow.webContents.openDevTools();
## Session Notes
+### [2026-09-21] IP-based location turned on, one day after turning it off
+
+**Context:** The PostHog migration shipped with `disableGeoip: true` and
+PRIVACY.md said, in two places, that location is never derived or stored. Asked
+the same day whether country was visible in the dashboard, the answer was no —
+by our own choice.
+
+**Decision:** Reverse it. Geo lookup is on; PostHog resolves the request IP to
+country/region on arrival and discards the IP. The alternative offered was
+device timezone (`Europe/Istanbul`), which answers the same question without
+touching a stated promise; it was declined in favour of the IP path.
+
+**What this cost, recorded so it is not forgotten:** a promise published in the
+morning was withdrawn in the afternoon. PRIVACY.md now has an **Approximate
+location** section stating what is derived rather than a line saying it is not —
+the doc is accurate again, but it is the second version of a claim about the same
+subject in one day. `NOTICE_VERSION` went to 3, so every user is interrupted a
+second time within days of the first card; the second card is what makes the
+change honest and also what makes the first one look provisional.
+
+**Rule reaffirmed (it held):** `NOTICE_VERSION` bumps when the disclosure
+changes. This is exactly that case, and bumping was not optional — collecting
+location under a card that says location is not collected would have been the
+real failure.
+
+**Open:** if location is ever narrowed or removed again, do not quietly drop the
+PRIVACY.md section — a third revision of the same claim needs to say what changed
+and why, or the document stops being read as a commitment.
+
+---
+
+### [2026-09-21] Analytics moved to PostHog — reversing the Aptabase decision
+
+**Context:** `audit-q3-product-analytics` (July) chose Aptabase deliberately and
+recorded a rejection of PostHog: *"PostHog's funnels/identity features exceed the
+spec's out-of-scope line (no per-user tracking)"* (`plan.md:11`). It measured
+activation as unique-users-per-event and accepted the imprecision. Two months of
+using that dashboard showed the cost: Aptabase attaches no identifier at all, so
+`app_started` cannot distinguish one person launching forty times from forty
+people launching once, and there is no funnel, no retention and no per-user
+feature usage. Every question worth asking about the roadmap turned out to be
+user-level.
+
+**Decision:** Reverse it. PostHog EU cloud, with a random per-install UUID as the
+distinct id. The July constraint was right about the trade — identity is a real
+privacy cost — and wrong about which side of it to take, because the alternative
+was not "less data", it was "no answers".
+
+Four choices shaped how the cost gets paid rather than avoided:
+
+- **Default-on opt-out kept**, not switched to opt-in. On a developer tool opt-in
+ yields 5–15% participation, and none of the questions survive that sample. The
+ cost is paid by disclosure instead.
+- **Anonymous only.** No self-identify field, no email. The PRIVACY.md line "no
+ email addresses or any personally identifiable information" stays true as
+ written.
+- **The notice re-shows.** People acknowledged a system with no identifier;
+ `telemetryNoticeShown` became `telemetryNoticeVersion` so the changed text
+ reaches them once. Default-on is only honest if the change is surfaced.
+- **Error detail on a separate opt-in channel**, not a redacted `message`
+ property on `error_occurred`. A free-form property would end the registry's
+ mechanically enum-only guarantee, and nothing would stop the next widening.
+
+**What did not change:** the `telemetryEvents.js` registry and `validateEvent`
+gate, the fail-closed opt-out, and the renderer-revalidated-in-main rule. Only
+`track()`'s final send call moved. No event was added — the user-level views come
+entirely from attaching the install id to the eleven events that already existed.
+
+**Rules established:**
+- Opting out **deletes** the install id; re-enabling mints a new one. An opt-out
+ that leaves a resumable identifier is not an opt-out.
+- `NOTICE_VERSION` is bumped only when the disclosure itself changes — it is not
+ a release counter, and a bump interrupts every user once.
+- The error channel never becomes an event. If exception detail is ever wanted in
+ the registry, that is a decision to re-open here, not a property to add.
+
+---
+
### [2026-01-25] Project Navigation System
**Context:** When Claude Code enters a project, it needs to quickly capture the context.
diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json
index 3817c028..fda0d075 100644
--- a/.frame/STRUCTURE.json
+++ b/.frame/STRUCTURE.json
@@ -94,7 +94,7 @@
"exports": [],
"depends": [],
"functions": {},
- "sizeBytes": 6928,
+ "sizeBytes": 11723,
"extraction": {
"status": "parsed"
},
@@ -290,7 +290,7 @@
"exports": [],
"depends": [],
"functions": {},
- "sizeBytes": 93321,
+ "sizeBytes": 94287,
"extraction": {
"status": "unsupported",
"reason": "no-extractor"
@@ -551,7 +551,7 @@
"child_process",
"fsSafe",
"logger",
- "telemetry",
+ "analytics",
"envPath",
"shared/ipcChannels"
],
@@ -695,6 +695,272 @@
}
}
},
+ "main/analytics": {
+ "file": "src/main/analytics.js",
+ "description": "Analytics",
+ "exports": [
+ "init",
+ "track",
+ "trackAppStarted",
+ "startActiveTimer",
+ "captureException",
+ "noticeState",
+ "setEnabled",
+ "isEnabled",
+ "isErrorReportingEnabled",
+ "migrateLegacySettings",
+ "enforceFailClosed",
+ "shutdown"
+ ],
+ "depends": [
+ "posthog-node",
+ "@aptabase/electron/main",
+ "node:crypto",
+ "electron",
+ "userSettings",
+ "analyticsEvents",
+ "pollGate"
+ ],
+ "functions": {
+ "init": {
+ "line": 88,
+ "purpose": "Initialize Aptabase and build the PostHog client."
+ },
+ "initAptabase": {
+ "line": 112
+ },
+ "distinctId": {
+ "line": 129,
+ "purpose": "This install's distinct id, resolved on first use and cached for the run."
+ },
+ "personProperties": {
+ "line": 148,
+ "purpose": "Person properties, sent once per run alongside the first event."
+ },
+ "track": {
+ "line": 161,
+ "params": [
+ "name",
+ "props"
+ ],
+ "purpose": "Send a registered anonymous event. No-op if disabled or not initialized."
+ },
+ "isErrorReportingEnabled": {
+ "line": 197,
+ "purpose": "Whether exception detail may be sent."
+ },
+ "captureException": {
+ "line": 214,
+ "params": [
+ "err"
+ ],
+ "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on."
+ },
+ "noticeState": {
+ "line": 241,
+ "purpose": "Whether the disclosure notice is due, and the version to store when it is"
+ },
+ "startActiveTimer": {
+ "line": 262,
+ "purpose": "Start counting active time."
+ },
+ "trackAppStarted": {
+ "line": 275,
+ "purpose": "Anonymous event marking this launch."
+ },
+ "setEnabled": {
+ "line": 289,
+ "params": [
+ "enabled"
+ ],
+ "purpose": "Toggle analytics from Settings. Persists the new state, then re-resolves"
+ },
+ "migrateLegacySettings": {
+ "line": 308,
+ "purpose": "Copy settings forward from the names this feature used when it was called"
+ },
+ "enforceFailClosed": {
+ "line": 332,
+ "purpose": "Make a failed settings load's fail-closed state stick. Called right after"
+ },
+ "isEnabled": {
+ "line": 343,
+ "purpose": "Effective enabled state. Default ON when the setting has never been"
+ },
+ "shutdown": {
+ "line": 359,
+ "purpose": "Flush the batcher and close the client, for the quit path."
+ }
+ },
+ "sizeBytes": 14849,
+ "extraction": {
+ "status": "parsed"
+ },
+ "provenance": {
+ "description": "94c116ee118a7299",
+ "purposes": {
+ "captureException": "9cb169eeec848b86",
+ "distinctId": "9c915fdaff2fafc5",
+ "enforceFailClosed": "ecf7a07c2aba26cb",
+ "init": "209680a766f4ec7c",
+ "isEnabled": "c193c02a32aa295b",
+ "isErrorReportingEnabled": "f650940efe39d9d6",
+ "migrateLegacySettings": "953fe743a0813ce2",
+ "noticeState": "09bcb4d505ea9526",
+ "personProperties": "0f5fbb80da306c1d",
+ "setEnabled": "d32777772151597e",
+ "shutdown": "6db4121e4d7045c2",
+ "startActiveTimer": "1132af442a4b3b3b",
+ "track": "2e86b64f1eda064b",
+ "trackAppStarted": "9c37f94c9015aeb9"
+ }
+ }
+ },
+ "main/analyticsEvents": {
+ "file": "src/main/analyticsEvents.js",
+ "description": "Analytics events — pure policy module",
+ "exports": [
+ "EVENTS",
+ "normalizeTool",
+ "validateEvent",
+ "effectiveEnabled",
+ "resolveInstallId",
+ "LEGACY_SETTING_KEYS",
+ "planLegacyMigration",
+ "sanitizeException",
+ "NOTICE_VERSION",
+ "shouldShowNotice",
+ "createActiveTimer",
+ "createRateLimiter",
+ "DEFAULT_RATE_LIMIT",
+ "diffSpecLifecycle",
+ "renameSpecLifecycle"
+ ],
+ "depends": [
+ "node:crypto",
+ "../scripts/redact"
+ ],
+ "functions": {
+ "normalizeTool": {
+ "line": 90,
+ "params": [
+ "id"
+ ],
+ "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool"
+ },
+ "validateEvent": {
+ "line": 105,
+ "params": [
+ "name",
+ "props"
+ ],
+ "purpose": "Validate an (event, props) pair against the registry."
+ },
+ "effectiveEnabled": {
+ "line": 143,
+ "params": [
+ "{ value",
+ "loadFailed }"
+ ],
+ "purpose": "Effective analytics state from the persisted setting plus settings-load"
+ },
+ "shouldShowNotice": {
+ "line": 173,
+ "params": [
+ "{ storedVersion",
+ "legacyShown",
+ "currentVersion }"
+ ],
+ "purpose": "Whether the disclosure notice should be shown."
+ },
+ "stripAbsolutePaths": {
+ "line": 201,
+ "params": [
+ "text"
+ ]
+ },
+ "sanitizeException": {
+ "line": 217,
+ "params": [
+ "err"
+ ],
+ "purpose": "Make an exception safe to send."
+ },
+ "createActiveTimer": {
+ "line": 242,
+ "params": [
+ "options"
+ ]
+ },
+ "planLegacyMigration": {
+ "line": 285,
+ "params": [
+ "snapshot"
+ ],
+ "purpose": "What the rename owes this install."
+ },
+ "resolveInstallId": {
+ "line": 327,
+ "params": [
+ "{ stored",
+ "enabled }",
+ "mint"
+ ],
+ "purpose": "Decide this launch's install id from what is stored and whether analytics"
+ },
+ "createRateLimiter": {
+ "line": 358,
+ "params": [
+ "options"
+ ],
+ "purpose": "A rolling-window limiter. `check(now)` decides one event and returns"
+ },
+ "stampedSince": {
+ "line": 418,
+ "params": [
+ "stamp",
+ "since"
+ ]
+ },
+ "diffSpecLifecycle": {
+ "line": 437,
+ "params": [
+ "previous",
+ "specs",
+ "now"
+ ],
+ "purpose": "Compare one push of a project's specs with this run's earlier looks at it."
+ },
+ "renameSpecLifecycle": {
+ "line": 476,
+ "params": [
+ "state",
+ "oldSlug",
+ "newSlug"
+ ],
+ "purpose": "Carry a renamed spec's history to its new slug, so the rename does not read"
+ }
+ },
+ "sizeBytes": 21026,
+ "extraction": {
+ "status": "parsed"
+ },
+ "provenance": {
+ "description": "d2887845f6deb252",
+ "purposes": {
+ "createRateLimiter": "f272874802425832",
+ "diffSpecLifecycle": "3b2433f402a29a53",
+ "effectiveEnabled": "1d7ad3bc41041a9d",
+ "normalizeTool": "dc8a3cfd5b8b95ee",
+ "planLegacyMigration": "252eb4a6737ba78e",
+ "renameSpecLifecycle": "abb27ce2d87a63f8",
+ "resolveInstallId": "c3af5851e884d6db",
+ "sanitizeException": "20a013c9fb3b910b",
+ "shouldShowNotice": "c5887cebeb237764",
+ "validateEvent": "bf0ab6cb2283257c"
+ }
+ }
+ },
"main/claudeSessionsManager": {
"file": "src/main/claudeSessionsManager.js",
"description": "Claude Sessions Manager Module",
@@ -995,22 +1261,23 @@
"depends": [
"electron",
"logger",
+ "analytics",
"shared/ipcChannels",
"userSettings"
],
"functions": {
"init": {
- "line": 21
+ "line": 24
},
"attachWindow": {
- "line": 71,
+ "line": 78,
"params": [
"window"
],
"purpose": "Wire per-window signals; called from createWindow."
},
"notify": {
- "line": 85,
+ "line": 92,
"params": [
"source",
"message"
@@ -1024,7 +1291,7 @@
"MAIN_PROCESS_ERROR"
]
},
- "sizeBytes": 3478,
+ "sizeBytes": 3864,
"extraction": {
"status": "parsed"
},
@@ -1476,7 +1743,7 @@
"../scripts/activity-log",
"specManager",
"fileTree",
- "telemetry"
+ "analytics"
],
"functions": {
"init": {
@@ -1827,7 +2094,7 @@
"FRAME_CONFIG_DATA"
]
},
- "sizeBytes": 71402,
+ "sizeBytes": 71396,
"extraction": {
"status": "parsed"
},
@@ -3050,7 +3317,7 @@
"uiZoom",
"gitStatusManager",
"gitDiffManager",
- "telemetry",
+ "analytics",
"specManager",
"orchestrationManager",
"structureLifecycle",
@@ -3058,26 +3325,26 @@
],
"functions": {
"confirmQuitWithLiveAgents": {
- "line": 57,
+ "line": 58,
"purpose": "Quitting (or closing the window) tears down every PTY — killing running"
},
"createWindow": {
- "line": 75,
+ "line": 76,
"purpose": "Create main application window"
},
"probeCoreDeps": {
- "line": 176
+ "line": 177
},
"setupAllIPC": {
- "line": 206,
+ "line": 207,
"purpose": "Setup all IPC handlers"
},
"init": {
- "line": 284,
+ "line": 297,
"purpose": "Initialize application"
},
"initModulesWithWindow": {
- "line": 330,
+ "line": 345,
"params": [
"window"
],
@@ -3088,8 +3355,10 @@
"listens": [
"GET_USER_SETTING",
"SET_USER_SETTING",
- "TELEMETRY_SET_ENABLED",
- "TELEMETRY_TRACK",
+ "ANALYTICS_SET_ENABLED",
+ "ANALYTICS_TRACK",
+ "ANALYTICS_EXCEPTION",
+ "ANALYTICS_NOTICE_STATE",
"GET_LOG_INFO",
"TERMINAL_INPUT",
"RECONCILE_TERMINALS"
@@ -3098,7 +3367,7 @@
"MAIN_PROCESS_ERROR"
]
},
- "sizeBytes": 14361,
+ "sizeBytes": 15545,
"extraction": {
"status": "parsed"
},
@@ -3541,7 +3810,7 @@
"ptyManager",
"gitBranchesManager",
"specManager",
- "telemetry",
+ "analytics",
"pollGate"
],
"functions": {
@@ -3911,7 +4180,7 @@
"os",
"child_process",
"shared/ipcChannels",
- "telemetry"
+ "analytics"
],
"functions": {
"execGit": {
@@ -4421,7 +4690,7 @@
"shared/ipcChannels",
"logger",
"promptLogger",
- "telemetry",
+ "analytics",
"pollGate",
"child_process",
"fs"
@@ -4632,8 +4901,8 @@
"tasksManager",
"commandStaging",
"frameProject",
- "telemetry",
- "telemetryEvents",
+ "analytics",
+ "analyticsEvents",
"activityLog",
"perfMonitor",
"../scripts/toolVocabulary",
@@ -5369,7 +5638,8 @@
"frameStore",
"activityLog",
"shared/ipcChannels",
- "shared/frameConstants"
+ "shared/frameConstants",
+ "analytics"
],
"functions": {
"cacheKeyFor": {
@@ -5451,14 +5721,14 @@
]
},
"deleteTask": {
- "line": 245,
+ "line": 252,
"params": [
"projectPath",
"taskId"
]
},
"reorderTasks": {
- "line": 264,
+ "line": 271,
"params": [
"projectPath",
"order"
@@ -5466,42 +5736,42 @@
"purpose": "Reorder tasks (used by the Kanban dashboard for drag-and-drop). The renderer"
},
"startWatching": {
- "line": 322,
+ "line": 329,
"params": [
"projectPath"
]
},
"onMetaFileChange": {
- "line": 371,
+ "line": 378,
"params": [
"listener"
]
},
"notifyMetaChange": {
- "line": 375,
+ "line": 382,
"params": [
"projectPath",
"filename"
]
},
"restartWatching": {
- "line": 390,
+ "line": 397,
"params": [
"projectPath"
],
"purpose": "Re-arm the watcher on the project's current meta directory. Migration moves"
},
"stopWatching": {
- "line": 395
+ "line": 402
},
"setupIPC": {
- "line": 405,
+ "line": 412,
"params": [
"ipcMain"
]
},
"getLastSelfWriteAt": {
- "line": 465,
+ "line": 472,
"purpose": "Timestamp of this module's last tasks.json write — exported so other"
}
},
@@ -5519,7 +5789,7 @@
"TASK_UPDATED"
]
},
- "sizeBytes": 15628,
+ "sizeBytes": 16046,
"extraction": {
"status": "parsed"
},
@@ -5536,158 +5806,6 @@
}
}
},
- "main/telemetry": {
- "file": "src/main/telemetry.js",
- "description": "Telemetry",
- "exports": [
- "init",
- "track",
- "trackAppStarted",
- "setEnabled",
- "isEnabled",
- "enforceFailClosed"
- ],
- "depends": [
- "@aptabase/electron/main",
- "userSettings",
- "telemetryEvents"
- ],
- "functions": {
- "init": {
- "line": 40,
- "purpose": "Initialize Aptabase. MUST be called before app.whenReady() because the"
- },
- "track": {
- "line": 57,
- "params": [
- "name",
- "props"
- ],
- "purpose": "Send a registered anonymous event. No-op if disabled or not initialized."
- },
- "trackAppStarted": {
- "line": 74,
- "purpose": "Anonymous event marking this launch."
- },
- "setEnabled": {
- "line": 82,
- "params": [
- "enabled"
- ],
- "purpose": "Toggle telemetry from Settings. Persists the new state. Aptabase is"
- },
- "enforceFailClosed": {
- "line": 100,
- "purpose": "Make a failed settings load's fail-closed state stick. Called right after"
- },
- "isEnabled": {
- "line": 111,
- "purpose": "Effective enabled state. Default ON when the setting has never been"
- }
- },
- "sizeBytes": 4335,
- "extraction": {
- "status": "parsed"
- },
- "provenance": {
- "description": "e87c57f595420aa9",
- "purposes": {
- "enforceFailClosed": "ecf7a07c2aba26cb",
- "init": "42ba849be1952f08",
- "isEnabled": "c193c02a32aa295b",
- "setEnabled": "4a841b01bfb46099",
- "track": "2e86b64f1eda064b",
- "trackAppStarted": "9c37f94c9015aeb9"
- }
- }
- },
- "main/telemetryEvents": {
- "file": "src/main/telemetryEvents.js",
- "description": "Telemetry events — pure policy module",
- "exports": [
- "EVENTS",
- "normalizeTool",
- "validateEvent",
- "effectiveEnabled",
- "createRateLimiter",
- "DEFAULT_RATE_LIMIT",
- "diffSpecLifecycle",
- "renameSpecLifecycle"
- ],
- "depends": [],
- "functions": {
- "normalizeTool": {
- "line": 63,
- "params": [
- "id"
- ],
- "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool"
- },
- "validateEvent": {
- "line": 78,
- "params": [
- "name",
- "props"
- ],
- "purpose": "Validate an (event, props) pair against the registry."
- },
- "effectiveEnabled": {
- "line": 102,
- "params": [
- "{ value",
- "loadFailed }"
- ],
- "purpose": "Effective telemetry state from the persisted setting plus settings-load"
- },
- "createRateLimiter": {
- "line": 128,
- "params": [
- "options"
- ],
- "purpose": "A rolling-window limiter. `check(now)` decides one event and returns"
- },
- "stampedSince": {
- "line": 188,
- "params": [
- "stamp",
- "since"
- ]
- },
- "diffSpecLifecycle": {
- "line": 207,
- "params": [
- "previous",
- "specs",
- "now"
- ],
- "purpose": "Compare one push of a project's specs with this run's earlier looks at it."
- },
- "renameSpecLifecycle": {
- "line": 246,
- "params": [
- "state",
- "oldSlug",
- "newSlug"
- ],
- "purpose": "Carry a renamed spec's history to its new slug, so the rename does not read"
- }
- },
- "sizeBytes": 10358,
- "extraction": {
- "status": "parsed"
- },
- "provenance": {
- "description": "2fd1c473a30f50f2",
- "purposes": {
- "createRateLimiter": "f272874802425832",
- "diffSpecLifecycle": "3b2433f402a29a53",
- "effectiveEnabled": "1f3cf0fb32313e34",
- "normalizeTool": "dc8a3cfd5b8b95ee",
- "renameSpecLifecycle": "abb27ce2d87a63f8",
- "validateEvent": "bf0ab6cb2283257c"
- }
- }
- },
"main/uiZoom": {
"file": "src/main/uiZoom.js",
"description": "UI zoom — the main-process owner of the interface scale (ui-zoom-steps spec).",
@@ -5836,7 +5954,7 @@
"path",
"electron",
"fsSafe",
- "telemetry"
+ "analytics"
],
"functions": {
"init": {
@@ -6016,7 +6134,7 @@
"exports": [],
"depends": [],
"functions": {},
- "sizeBytes": 216296,
+ "sizeBytes": 217739,
"extraction": {
"status": "unsupported",
"reason": "no-extractor"
@@ -6028,7 +6146,7 @@
"exports": [],
"depends": [],
"functions": {},
- "sizeBytes": 3747,
+ "sizeBytes": 3778,
"extraction": {
"status": "unsupported",
"reason": "no-extractor"
@@ -6405,107 +6523,107 @@
"purpose": "The spec.implement path: modal → record implement_mode → stage → dispatch."
},
"_isSpecCreatorLane": {
- "line": 615,
+ "line": 620,
"params": [
"terminalId"
],
"purpose": "Still open, and still carrying the transient name we gave it."
},
"_onSpecData": {
- "line": 631,
+ "line": 636,
"params": [
"specs"
],
"purpose": "A new slug appeared — attach it to the lane that produced it."
},
"_bindSpecCreatorLane": {
- "line": 651,
+ "line": 656,
"params": [
"terminalId",
"slug"
]
},
"dispatchSpecNew": {
- "line": 687,
+ "line": 692,
"params": [
"description"
],
"purpose": "Hand the user's free-form text to the standard `spec.new` flow."
},
"getSpecLaneInfo": {
- "line": 775,
+ "line": 780,
"params": [
"slug"
],
"purpose": "Live info about the lane a spec is assigned to, or null when unassigned"
},
"getTaskLaneInfo": {
- "line": 799,
+ "line": 804,
"params": [
"taskId"
],
"purpose": "Live info about the lane working on a task, or null. Unlike specs there"
},
"specStatusDotHtml": {
- "line": 820,
+ "line": 825,
"params": [
"slug"
],
"purpose": "Activity dot for spec/task rows and detail headers: pulsing while the"
},
"taskStatusDotHtml": {
- "line": 824,
+ "line": 829,
"params": [
"taskId"
]
},
"_activityDotHtml": {
- "line": 828,
+ "line": 833,
"params": [
"info"
]
},
"onSpecLaneActivity": {
- "line": 845,
+ "line": 850,
"params": [
"callback"
],
"purpose": "Subscribe to spec-lane activity. Callback: (slug) — consumers re-read"
},
"_notifySpecLane": {
- "line": 853,
+ "line": 858,
"params": [
"slug"
]
},
"onTaskLaneActivity": {
- "line": 876,
+ "line": 881,
"params": [
"callback"
],
"purpose": "Subscribe to task-lane activity. Callback: (taskId|null) — null means"
},
"_notifyTaskLane": {
- "line": 884,
+ "line": 889,
"params": [
"taskId"
]
},
"_assignedLane": {
- "line": 902,
+ "line": 907,
"params": [
"slug"
],
"purpose": "Assigned lane for a slug, validated against open terminals — a closed"
},
"_laneName": {
- "line": 912,
+ "line": 917,
"params": [
"terminalId"
]
},
"_askContinueOrNew": {
- "line": 921,
+ "line": 926,
"params": [
"frameName",
"specName"
@@ -6513,14 +6631,14 @@
"purpose": "\"Continue in / Open a new Frame\" — always asked when the spec"
},
"_waitForAgentReady": {
- "line": 962,
+ "line": 967,
"params": [
"terminalId"
],
"purpose": "Cold-start readiness gate: resolves true once laneStatus sees a known"
},
"_fail": {
- "line": 991,
+ "line": 996,
"params": [
"terminalId",
"message"
@@ -6533,12 +6651,13 @@
"TERMINAL_DESTROYED"
],
"emits": [
- "TELEMETRY_TRACK",
- "TELEMETRY_TRACK",
+ "ANALYTICS_TRACK",
+ "ANALYTICS_TRACK",
+ "ANALYTICS_TRACK",
"SPEC_AGENT_ACTIVITY"
]
},
- "sizeBytes": 41248,
+ "sizeBytes": 41538,
"extraction": {
"status": "parsed"
},
@@ -6656,6 +6775,35 @@
}
}
},
+ "renderer/analyticsNotice": {
+ "file": "src/renderer/analyticsNotice.js",
+ "description": "Analytics Notice Banner",
+ "exports": [
+ "init"
+ ],
+ "depends": [
+ "electron",
+ "shared/ipcChannels"
+ ],
+ "functions": {
+ "init": {
+ "line": 31,
+ "params": [
+ "openSettings"
+ ]
+ },
+ "dismiss": {
+ "line": 60
+ }
+ },
+ "sizeBytes": 2518,
+ "extraction": {
+ "status": "parsed"
+ },
+ "provenance": {
+ "description": "e8f5bd6a1459bcb7"
+ }
+ },
"renderer/appHeader": {
"file": "src/renderer/appHeader.js",
"description": "App Header — the one row across the top of the window",
@@ -7316,6 +7464,8 @@
"lucideIcon"
],
"depends": [
+ "electron",
+ "shared/ipcChannels",
"dock/dockState",
"dnd/sortable",
"tooltip",
@@ -7331,7 +7481,7 @@
],
"functions": {
"lucideIcon": {
- "line": 44,
+ "line": 46,
"params": [
"data",
"size = 14"
@@ -7339,7 +7489,7 @@
"purpose": "Inline SVG for a lucide icon's data (the shape sectionRail.js uses)."
},
"emptyState": {
- "line": 53,
+ "line": 55,
"params": [
"title",
"message"
@@ -7347,7 +7497,7 @@
"purpose": "The dock's inline empty state — the shape decisionsView.renderEmpty uses."
},
"panelTab": {
- "line": 72,
+ "line": 74,
"params": [
"{ label",
"icon",
@@ -7357,116 +7507,122 @@
"purpose": "A tab hosting one of the legacy side panels (D9). The element keeps its"
},
"loadState": {
- "line": 190
+ "line": 192
},
"persist": {
- "line": 198
+ "line": 200
},
"init": {
- "line": 208
+ "line": 210
},
"available": {
- "line": 280,
+ "line": 282,
"params": [
"position = state.position"
],
"purpose": "The center's extent on the dock's axis, for the size ceiling."
},
"applySize": {
- "line": 289
+ "line": 291
},
"apply": {
- "line": 306,
+ "line": 308,
"params": [
"{ refit = false } = {}"
],
"purpose": "Paint `state`: side class, size, open/closed, active tab, mounted"
},
"mountTab": {
- "line": 344,
+ "line": 346,
"params": [
"tab"
]
},
"unmountTab": {
- "line": 358
+ "line": 364
},
"refitCenter": {
- "line": 373,
+ "line": 379,
"purpose": "Terminals refit through the existing fit path (D13)."
},
"refitTab": {
- "line": 380,
+ "line": 386,
"purpose": "The active tab's own refit (Structure re-renders its graph to the new box)."
},
"emit": {
- "line": 391
+ "line": 397
},
"snapshotState": {
- "line": 402
+ "line": 408
},
"bindResize": {
- "line": 408,
+ "line": 414,
"params": [
"handle"
]
},
"ensureReady": {
- "line": 468
+ "line": 474
},
"open": {
- "line": 477,
+ "line": 483,
"params": [
"tab"
],
"purpose": "Open on `tab` (or the last tab). A tab switch on an open dock is not a size change."
},
"close": {
- "line": 484
+ "line": 490
},
"toggle": {
- "line": 492,
+ "line": 498,
"purpose": "⌘J: open ↔ closed, reopening on the last tab at the last size."
},
"toggleTab": {
- "line": 498,
+ "line": 504,
"params": [
"tab"
],
"purpose": "Status-bar / menu semantics: on it → close; on another → switch; closed → open on it."
},
"setPosition": {
- "line": 505,
+ "line": 511,
"params": [
"position"
]
},
"isOpen": {
- "line": 513
+ "line": 519
},
"activeTab": {
- "line": 517
+ "line": 523
},
"position": {
- "line": 521
+ "line": 527
},
"remountActive": {
- "line": 530,
+ "line": 536,
"purpose": "Re-run the active tab's mount (the project changed under an open"
},
"tabOrder": {
- "line": 538,
+ "line": 544,
"purpose": "The strip's current order of tab ids (the user's arrangement)."
},
"onChange": {
- "line": 543,
+ "line": 549,
"params": [
"fn"
],
"purpose": "Follow the dock: fn({ open, position, tab, order }) after every change."
}
},
- "sizeBytes": 18489,
+ "ipc": {
+ "listens": [],
+ "emits": [
+ "ANALYTICS_TRACK"
+ ]
+ },
+ "sizeBytes": 18854,
"extraction": {
"status": "parsed"
},
@@ -8049,7 +8205,7 @@
"ipc": {
"listens": [],
"emits": [
- "TELEMETRY_TRACK"
+ "ANALYTICS_TRACK"
]
},
"sizeBytes": 15853,
@@ -8327,19 +8483,19 @@
],
"functions": {
"init": {
- "line": 45
+ "line": 47
},
"initAboutSection": {
- "line": 104
+ "line": 124
},
"runCheck": {
- "line": 189,
+ "line": 209,
"params": [
"userInitiated"
]
},
"renderUpdateState": {
- "line": 228,
+ "line": 248,
"params": [
"{ checked",
"found",
@@ -8351,32 +8507,39 @@
]
},
"showUpdateBanner": {
- "line": 252,
+ "line": 272,
"params": [
"info"
]
},
"hideUpdateBanner": {
- "line": 263
+ "line": 283
},
"hideSidebarDot": {
- "line": 267
+ "line": 287
},
"formatRelative": {
- "line": 273,
+ "line": 293,
"params": [
"date"
]
},
"initZoomSelect": {
- "line": 289,
+ "line": 309,
"purpose": "Fill the Interface size select from the shared ladder — one option per"
},
"syncZoomFromMain": {
- "line": 308
+ "line": 332
+ },
+ "syncErrorReportingAvailability": {
+ "line": 348,
+ "params": [
+ "analyticsOn"
+ ],
+ "purpose": "Error reporting is gated on analytics in main (analytics.captureException"
},
"syncToggleFromSettings": {
- "line": 318
+ "line": 355
}
},
"ipc": {
@@ -8385,16 +8548,22 @@
"OPEN_SETTINGS",
"UPDATE_AVAILABLE"
],
- "emits": []
+ "emits": [
+ "ANALYTICS_TRACK",
+ "ANALYTICS_TRACK",
+ "ANALYTICS_TRACK",
+ "ANALYTICS_TRACK"
+ ]
},
- "sizeBytes": 11647,
+ "sizeBytes": 14043,
"extraction": {
"status": "parsed"
},
"provenance": {
"description": "1dffdbc6d50a7e19",
"purposes": {
- "initZoomSelect": "cbe76526d4d65826"
+ "initZoomSelect": "cbe76526d4d65826",
+ "syncErrorReportingAvailability": "f99d7b4f4c5c647a"
}
}
},
@@ -9459,30 +9628,30 @@
"purpose": "End the tour and remember it. `outcome` is 'finished' or 'skipped'. A write"
},
"hasProject": {
- "line": 152
+ "line": 155
},
"start": {
- "line": 160,
+ "line": 163,
"purpose": "Start (or restart) the tour from the first step that applies: step 1 with"
},
"close": {
- "line": 179,
+ "line": 182,
"purpose": "Leave the tour: remove the layer and every listener it bound."
},
"next": {
- "line": 189
+ "line": 192
},
"ensureNodes": {
- "line": 200
+ "line": 203
},
"isVisible": {
- "line": 228,
+ "line": 231,
"params": [
"el"
]
},
"resolveTarget": {
- "line": 242,
+ "line": 245,
"params": [
"step",
"{ reveal = false } = {}"
@@ -9490,47 +9659,47 @@
"purpose": "The step's first usable target, with its entry."
},
"isAvailable": {
- "line": 255,
+ "line": 258,
"params": [
"step"
]
},
"revealNav": {
- "line": 259,
+ "line": 262,
"params": [
"view"
]
},
"show": {
- "line": 272,
+ "line": 275,
"params": [
"i"
]
},
"skipMissing": {
- "line": 293,
+ "line": 296,
"params": [
"i"
],
"purpose": "A step whose target is gone: log it once, move on, never end the tour on it."
},
"countedSteps": {
- "line": 312,
+ "line": 315,
"purpose": "The steps this run of the tour counts: step 1 only if it began without a project."
},
"renderCard": {
- "line": 316,
+ "line": 319,
"params": [
"step",
"i"
]
},
"focusCard": {
- "line": 363,
+ "line": 366,
"purpose": "Put focus on the card's primary control so Enter, → and Escape reach it."
},
"el": {
- "line": 369,
+ "line": 372,
"params": [
"tag",
"className",
@@ -9538,37 +9707,43 @@
]
},
"onCardClick": {
- "line": 376,
+ "line": 379,
"params": [
"e"
]
},
"onCardKeydown": {
- "line": 395,
+ "line": 398,
"params": [
"e"
],
"purpose": "Bound on the card, not the document: the keys answer only while focus is in"
},
"position": {
- "line": 412,
+ "line": 415,
"purpose": "Measure the current target and move the cutout and the card to it."
},
"queueReposition": {
- "line": 449,
+ "line": 452,
"purpose": "Coalesce bursts (a sidebar drag, a scroll) into one measurement per frame."
},
"observeTarget": {
- "line": 458,
+ "line": 461,
"params": [
"target"
]
},
"bindWhileOpen": {
- "line": 465
+ "line": 468
}
},
- "sizeBytes": 16813,
+ "ipc": {
+ "listens": [],
+ "emits": [
+ "ANALYTICS_TRACK"
+ ]
+ },
+ "sizeBytes": 17021,
"extraction": {
"status": "parsed"
},
@@ -10249,7 +10424,7 @@
"doneWindow",
"frameSettingsModal",
"feedbackPanel",
- "telemetryNotice",
+ "analyticsNotice",
"healthNotice",
"specDrivenHint",
"docsHealthHint",
@@ -10295,6 +10470,13 @@
"startAiSession": {
"line": 991,
"purpose": "Start the selected AI tool (Claude Code / Codex CLI / etc.) in a fresh"
+ },
+ "forwardException": {
+ "line": 1016,
+ "params": [
+ "err"
+ ],
+ "purpose": "Renderer exceptions reach the same opt-in channel the main process uses."
}
},
"ipc": {
@@ -10305,16 +10487,18 @@
"UPDATE_AVAILABLE"
],
"emits": [
- "REFRESH_GIT_STATUS"
+ "REFRESH_GIT_STATUS",
+ "ANALYTICS_EXCEPTION"
]
},
- "sizeBytes": 35701,
+ "sizeBytes": 36509,
"extraction": {
"status": "parsed"
},
"provenance": {
"description": "15084cdcaeca3e5f",
"purposes": {
+ "forwardException": "50c359f5f5349218",
"init": "963690fd5a00420d",
"registerCommands": "a3e9675a98b7208e",
"revealSidebarTab": "0ebc0110a1121c81",
@@ -11047,7 +11231,7 @@
"ORCH_STATE"
],
"emits": [
- "TELEMETRY_TRACK",
+ "ANALYTICS_TRACK",
"ORCH_WORKER_LANE"
]
},
@@ -12244,14 +12428,20 @@
"purpose": "Resume a session in a terminal of its own."
},
"formatRelativeTime": {
- "line": 209,
+ "line": 213,
"params": [
"dateString"
],
"purpose": "Format a date string to relative time"
}
},
- "sizeBytes": 7637,
+ "ipc": {
+ "listens": [],
+ "emits": [
+ "ANALYTICS_TRACK"
+ ]
+ },
+ "sizeBytes": 7860,
"extraction": {
"status": "parsed"
},
@@ -14126,6 +14316,18 @@
"reason": "no-extractor"
}
},
+ "renderer/styles/components/analytics-notice.css": {
+ "file": "src/renderer/styles/components/analytics-notice.css",
+ "description": "",
+ "exports": [],
+ "depends": [],
+ "functions": {},
+ "sizeBytes": 2580,
+ "extraction": {
+ "status": "unsupported",
+ "reason": "no-extractor"
+ }
+ },
"renderer/styles/components/app-header.css": {
"file": "src/renderer/styles/components/app-header.css",
"description": "",
@@ -14372,7 +14574,7 @@
"exports": [],
"depends": [],
"functions": {},
- "sizeBytes": 7552,
+ "sizeBytes": 8092,
"extraction": {
"status": "unsupported",
"reason": "no-extractor"
@@ -14426,18 +14628,6 @@
"reason": "no-extractor"
}
},
- "renderer/styles/components/telemetry-notice.css": {
- "file": "src/renderer/styles/components/telemetry-notice.css",
- "description": "",
- "exports": [],
- "depends": [],
- "functions": {},
- "sizeBytes": 2580,
- "extraction": {
- "status": "unsupported",
- "reason": "no-extractor"
- }
- },
"renderer/styles/components/terminal.css": {
"file": "src/renderer/styles/components/terminal.css",
"description": "",
@@ -15259,35 +15449,6 @@
}
}
},
- "renderer/telemetryNotice": {
- "file": "src/renderer/telemetryNotice.js",
- "description": "Telemetry Notice Banner",
- "exports": [
- "init"
- ],
- "depends": [
- "electron",
- "shared/ipcChannels"
- ],
- "functions": {
- "init": {
- "line": 21,
- "params": [
- "openSettings"
- ]
- },
- "dismiss": {
- "line": 48
- }
- },
- "sizeBytes": 1921,
- "extraction": {
- "status": "parsed"
- },
- "provenance": {
- "description": "00ef406cce3b4f98"
- }
- },
"renderer/terminal": {
"file": "src/renderer/terminal.js",
"description": "Terminal UI Module",
@@ -17015,7 +17176,7 @@
"exports": [],
"depends": [],
"functions": {},
- "sizeBytes": 4013,
+ "sizeBytes": 5125,
"extraction": {
"status": "unsupported",
"reason": "no-extractor"
@@ -21106,7 +21267,7 @@
],
"depends": [],
"functions": {},
- "sizeBytes": 11799,
+ "sizeBytes": 11930,
"extraction": {
"status": "parsed"
},
@@ -21971,6 +22132,33 @@
"description": "a806f9f813564656"
}
},
+ "test/analytics.test": {
+ "file": "test/analytics.test.js",
+ "description": "Analytics policy tests — fail-closed opt-out decision.",
+ "exports": [],
+ "depends": [
+ "node:test",
+ "node:assert/strict",
+ "src/main/analyticsEvents",
+ "fs",
+ "path"
+ ],
+ "functions": {
+ "seed": {
+ "line": 418,
+ "params": [
+ "specs"
+ ]
+ }
+ },
+ "sizeBytes": 21440,
+ "extraction": {
+ "status": "parsed"
+ },
+ "provenance": {
+ "description": "9d61943b8b213ee4"
+ }
+ },
"test/branchPickerModel.test": {
"file": "test/branchPickerModel.test.js",
"description": "The status bar branch picker's list model (status-bar-branch-picker spec,",
@@ -24972,33 +25160,6 @@
"description": "494edc14d60ce825"
}
},
- "test/telemetry.test": {
- "file": "test/telemetry.test.js",
- "description": "Telemetry policy tests — fail-closed opt-out decision.",
- "exports": [],
- "depends": [
- "node:test",
- "node:assert/strict",
- "src/main/telemetryEvents",
- "fs",
- "path"
- ],
- "functions": {
- "seed": {
- "line": 186,
- "params": [
- "specs"
- ]
- }
- },
- "sizeBytes": 11255,
- "extraction": {
- "status": "parsed"
- },
- "provenance": {
- "description": "02f0cdc3a91d93c8"
- }
- },
"test/themes.test": {
"file": "test/themes.test.js",
"description": "The theme registry (src/renderer/themes.js).",
@@ -25941,6 +26102,28 @@
"direction": "",
"description": ""
}
+ },
+ "analytics": {
+ "ANALYTICS_SET_ENABLED": {
+ "name": "analytics-set-enabled",
+ "direction": "",
+ "description": ""
+ },
+ "ANALYTICS_TRACK": {
+ "name": "analytics-track",
+ "direction": "",
+ "description": ""
+ },
+ "ANALYTICS_EXCEPTION": {
+ "name": "analytics-exception",
+ "direction": "",
+ "description": ""
+ },
+ "ANALYTICS_NOTICE_STATE": {
+ "name": "analytics-notice-state",
+ "direction": "",
+ "description": ""
+ }
}
},
"dataFlow": [],
@@ -25988,6 +26171,18 @@
"description": "AI Tool Selector Module"
}
],
+ "analytics": [
+ {
+ "module": "main/analytics",
+ "file": "src/main/analytics.js",
+ "description": "Analytics"
+ },
+ {
+ "module": "renderer/analyticsNotice",
+ "file": "src/renderer/analyticsNotice.js",
+ "description": "Analytics Notice Banner"
+ }
+ ],
"branch": [
{
"module": "main/gitBranchRefs",
@@ -26113,9 +26308,9 @@
],
"events": [
{
- "module": "main/telemetryEvents",
- "file": "src/main/telemetryEvents.js",
- "description": "Telemetry events — pure policy module"
+ "module": "main/analyticsEvents",
+ "file": "src/main/analyticsEvents.js",
+ "description": "Analytics events — pure policy module"
},
{
"module": "shared/activityEvents",
@@ -26722,18 +26917,6 @@
"description": "Tasks Panel Module"
}
],
- "telemetry": [
- {
- "module": "main/telemetry",
- "file": "src/main/telemetry.js",
- "description": "Telemetry"
- },
- {
- "module": "renderer/telemetryNotice",
- "file": "src/renderer/telemetryNotice.js",
- "description": "Telemetry Notice Banner"
- }
- ],
"terminal": [
{
"module": "main/pty",
diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/digest.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/digest.md
new file mode 100644
index 00000000..93004806
--- /dev/null
+++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/digest.md
@@ -0,0 +1,21 @@
+---
+keywords: posthog, analytics, install id, funnels, retention, error tracking, telemetry, opt-out, notice version
+related: audit-q3-product-analytics, audit-q3-reliability-recovery
+---
+Moved analytics off Aptabase onto PostHog EU (`posthog-node`, geoip disabled),
+explicitly reversing audit-q3-product-analytics's vendor decision: Aptabase
+attaches no identifier, so unique users, funnels and retention were structurally
+unanswerable. No event was added — the user-level views come from attaching a
+random per-install UUID to the eleven events that already existed; `track()`'s
+gate order and `validateEvent` are untouched. Opting out deletes the id
+(re-enable mints a new one). Rejected: opt-in analytics (5-15% sample kills the
+questions), self-identify (keeps PII out), personless mode, dual-send, and a
+redacted `message` prop on `error_occurred` — that last one would have ended the
+registry's enum-only guarantee, so exception detail got a separate opt-in channel
+(`errorReportingEnabled`, default off) that sanitizes via redact.js plus absolute
+path→basename and never touches the registry. Notice became versioned so the
+changed disclosure reaches people who dismissed the old one. Rules: the error
+channel never becomes an event; `NOTICE_VERSION` bumps only when the disclosure
+changes; new policy goes in the pure module (CI runs without `npm ci`).
+
+Chain: spec.md → plan.md → tasks.md → outcome.md
diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md
new file mode 100644
index 00000000..fe5a31e7
--- /dev/null
+++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md
@@ -0,0 +1,84 @@
+# Outcome — PostHog analytics — identity, funnels and errors
+
+## T01 — resolveInstallId in the pure policy module
+
+Added `resolveInstallId({ stored, enabled }, mint)` to the pure policy module with six tests. Refined plan.md's `{ id, write }` contract: `write` is a three-state instruction (`'set'` | `'delete'` | `null`) rather than a boolean, because telemetry-off has to distinguish deleting a stored id from doing nothing when none exists — a boolean could not carry that. A malformed stored value is replaced rather than sent, since it can only come from a hand-edited or half-written settings file. Files touched: `src/main/telemetryEvents.js`, `test/telemetry.test.js`.
+
+_Captured: 2026-09-21 · 2 file change(s)_
+
+---
+
+## T02 — Replace Aptabase with posthog-node as the transport
+
+Replaced `@aptabase/electron` with `posthog-node` (v5.52.5) and rewrote `telemetry.js` around `client.capture({ distinctId, event, properties })` on the EU host with geoip disabled; `track()`'s gate order is unchanged. Two things the plan did not spell out: the install id is resolved **lazily** on first send rather than in `init()`, because `userSettings` loads after `init()` and an early read would mint an id for someone who had opted out; and `init()` now refuses to build a client while the API key is still the placeholder, so the app runs and sends nothing instead of throwing. Person properties are `$os` and `$app_version` only, sent once per run. Files touched: `src/main/telemetry.js`, `package.json`, `package-lock.json`.
+
+Followup: the PostHog project API key is still `phc_REPLACE_WITH_PROJECT_API_KEY` — nothing is sent until it is filled in.
+
+_Captured: 2026-09-21 · 3 file change(s)_
+
+---
+
+## T03 — Flush the batcher on the quit path
+
+Added `telemetry.shutdown()` (awaited `client.shutdown(2000)`, never throwing) and wired it into the existing `before-quit` handler. Went beyond plan.md's one line deliberately: a fire-and-forget call there does not flush, because Electron continues quitting and the process dies mid-request — so the handler now preventDefaults once, flushes, then re-quits, reusing the shape the live-agent confirmation already established. A `telemetryFlushed` latch keeps the second pass from looping. Files touched: `src/main/telemetry.js`, `src/main/index.js`.
+
+_Captured: 2026-09-21 · 2 file change(s)_
+
+---
+
+## T04 — Opt-out destroys the install id
+
+Made `setEnabled` re-resolve the install id so the toggle lands on disk at once — off deletes the stored id, on mints a new one. Added four composition tests over `effectiveEnabled` + `resolveInstallId`; the fail-closed cases now assert not just that nothing is sent but that nothing is kept, which is the stronger claim PRIVACY.md will make. A returning user who opted out and back in is deliberately a new user to the dashboard. Files touched: `src/main/telemetry.js`, `test/telemetry.test.js`.
+
+_Captured: 2026-09-21 · 2 file change(s)_
+
+---
+
+## T05 — sanitizeException in the pure policy module
+
+Added `sanitizeException` to the pure module: redact first, then strip absolute paths to basenames on both POSIX and Windows shapes. Two refinements the plan did not specify — the POSIX pattern requires two or more segments and a `(?/…`, `C:\Users\\…`, and any path outside the app bundle reduced to its basename).
+- `shouldShowNotice({ storedVersion, currentVersion })` — the versioned replacement for the boolean, including the migration case where an old client stored `telemetryNoticeShown: true` and has no version.
+
+The error channel is deliberately separate end to end: a different setting, a different function (`telemetry.captureException`), a different IPC channel, and a PostHog call that never passes through the event registry.
+
+```
+main exception → crashGuard.js ─┐
+renderer error → TELEMETRY_EXCEPTION ─┤→ telemetry.captureException()
+ │ ↳ errorReportingEnabled? → sanitizeException() → posthog.captureException()
+registry events → track() → validateEvent() → rateLimiter → posthog.capture({distinctId})
+```
+
+## Files
+
+- `package.json` — **Modified** — drop `@aptabase/electron`, add `posthog-node`.
+- `src/main/telemetry.js` — **Modified** — PostHog client (EU host, `disableGeoip`), install-ID resolution, `distinctId` on every capture, `captureException`, `shutdown`, corrected module header.
+- `src/main/telemetryEvents.js` — **Modified** — adds `resolveInstallId`, `sanitizeException`, `shouldShowNotice`, `NOTICE_VERSION`. Registry, `validateEvent`, `effectiveEnabled` and the rate limiter are untouched.
+- `src/main/crashGuard.js` — **Modified** — main-process `uncaughtException` / `unhandledRejection` also reach `telemetry.captureException`.
+- `src/main/index.js` — **Modified** — `TELEMETRY_EXCEPTION` listener; `posthog.shutdown()` in the existing `before-quit` handler.
+- `src/shared/ipcChannels.js` — **Modified** — `TELEMETRY_EXCEPTION` constant beside the existing telemetry channels.
+- `src/renderer/index.js` — **Modified** — `window.onerror` / `unhandledrejection` hooks forwarding to the new channel.
+- `src/renderer/telemetryNotice.js` — **Modified** — versioned notice key, new disclosure copy.
+- `src/renderer/frameSettingsModal.js` — **Modified** — wire the error-reporting toggle.
+- `index.html` — **Modified** — error-reporting row in Privacy & Analytics; notice copy.
+- `PRIVACY.md` — **Modified** — PostHog, EU residency, the install ID and what it is not, the unchanged event table, the error-reporting opt-in.
+- `test/telemetry.test.js` — **Modified** — install-ID lifecycle, exception sanitization, notice versioning; existing fail-closed and registry assertions stay.
+
+## Footprint
+
+- package.json
+- src/main/telemetry.js
+- src/main/telemetryEvents.js
+- src/main/crashGuard.js
+- src/main/index.js
+- src/shared/ipcChannels.js
+- src/renderer/index.js
+- src/renderer/telemetryNotice.js
+- src/renderer/frameSettingsModal.js
+- index.html
+- PRIVACY.md
+- test/telemetry.test.js
+
+## Dependencies
+
+- **Add `posthog-node`** — the analytics transport. Main-process only; the renderer keeps reaching telemetry over IPC, so no renderer bundle growth.
+- **Remove `@aptabase/electron`** — replaced outright (hard cutover decision).
+
+## Sequencing
+
+1. **Install-ID policy, pure.** Add `resolveInstallId` to `telemetryEvents.js` with its tests: mint-when-absent, reuse-when-stored, reject a malformed stored value, and return null with a delete instruction when telemetry is off.
+2. **Swap the transport.** Replace `@aptabase/electron` with `posthog-node` in `package.json` and `telemetry.js`: client on `https://eu.i.posthog.com` with `disableGeoip: true`, `distinctId` from step 1 on every `capture`, `$set` of OS and app version on the first capture of a launch, corrected module header. `track()`'s gate order stays exactly as it is.
+3. **Flush on exit.** Call `posthog.shutdown()` from the existing `before-quit` handler so a session's last events are not dropped by the batcher.
+4. **Opt-out lifecycle.** `setEnabled(false)` deletes the stored install ID; re-enabling mints a fresh one. `enforceFailClosed()` is untouched. Extend the existing fail-closed tests to assert the ID is gone when telemetry is off.
+5. **Exception sanitization, pure.** Add `sanitizeException` to `telemetryEvents.js` with its tests: secrets redacted via `scripts/redact.js`, absolute user paths reduced to basenames on both POSIX and Windows shapes, and an input carrying a prompt-like blob coming out with no absolute path.
+6. **The error channel.** Add `errorReportingEnabled` (default `false`) and `telemetry.captureException`, gated on that setting *and* on `isEnabled()`, and passed through the **same rate limiter instance** `track()` uses — a repeating exception in a render loop must not be able to drain the quota. Wire `crashGuard.js`'s two handlers into it.
+7. **Renderer exceptions.** Add `TELEMETRY_EXCEPTION` to `ipcChannels.js`, the `ipcMain.on` listener in `index.js` (sanitization happens in main, matching how `TELEMETRY_TRACK` revalidates), and the `window.onerror` / `unhandledrejection` hooks in `src/renderer/index.js`.
+8. **Settings surface.** Error-reporting row in the Privacy & Analytics section of `index.html`, wired in `frameSettingsModal.js` next to the crash-dumps toggle.
+9. **Notice re-version.** `shouldShowNotice` + `NOTICE_VERSION` in `telemetryEvents.js` with tests (fresh install, old boolean-only client, already-seen current version); `telemetryNotice.js` reads the versioned key and carries copy that names the install ID.
+10. **Disclosure.** Rewrite `PRIVACY.md` — vendor, EU residency, the install ID and what it is not, the unchanged event table, the error-reporting opt-in — and record the reversal of `audit-q3-product-analytics`'s vendor decision in `PROJECT_NOTES.md` as a dated entry.
diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/spec.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/spec.md
new file mode 100644
index 00000000..a40be79a
--- /dev/null
+++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/spec.md
@@ -0,0 +1,128 @@
+---
+keywords: posthog, analytics, funnels, retention, unique users, error tracking, telemetry, opt-out
+related: audit-q3-product-analytics, audit-q3-reliability-recovery, audit-q3-ux-error-feedback
+---
+
+# PostHog analytics — identity, funnels and errors
+
+## Problem
+
+Frame's telemetry (Aptabase, 11-event registry) reports **event counts**, and
+that is structurally all it can report: Aptabase attaches no user identifier,
+so there is no way to ask how many distinct people use Frame, whether the same
+people come back, what one person does across a session, or where they stop.
+
+The founder's actual questions are all user-level and none are answerable
+today:
+
+- **How many unique users are there? Who is actively using Frame?** — `app_started`
+ count conflates one user launching 40 times with 40 users launching once.
+- **What do users do inside?** — per-event totals exist, but not "of the people
+ who opened the orchestrator, how many ever ran one".
+- **Where are we losing them?** — the install → `project_initialized` →
+ `spec_created` → `agent_run_started` funnel has no drop-off view. The
+ predecessor spec knowingly approximated activation as *unique users per plain
+ event* (`audit-q3-product-analytics/plan.md:7`); that approximation is now the
+ bottleneck.
+- **What breaks in the wild?** — `error_occurred` carries a 9-value category and
+ nothing else. It reports that something failed N times, never what failed.
+
+`audit-q3-product-analytics` chose Aptabase deliberately and **rejected PostHog**
+because "identity/funnels exceed the spec's out-of-scope line (no per-user
+tracking)" (`plan.md:11`). This spec **reverses that decision**: the questions
+the founder needs answered are user-level, so a pseudonymous per-install
+identifier is now accepted — with its privacy cost paid explicitly in
+`PRIVACY.md` rather than avoided.
+
+## Goal
+
+Product analytics on **PostHog EU cloud**, with a stable anonymous install ID,
+so the dashboard answers: unique users, DAU/WAU/MAU, retention cohorts, the
+activation funnel with per-step drop-off, and feature usage per user — plus a
+separate, opt-in error-tracking path that carries redacted exception detail
+instead of a bare category.
+
+The registry allowlist, the fail-closed opt-out and PRIVACY.md accuracy survive
+the move unchanged in spirit: PostHog replaces the transport and the dashboard,
+not Frame's discipline about what may leave the machine.
+
+## Constraints
+
+- **Reverses a recorded decision.** `audit-q3-product-analytics` (`plan.md:11`)
+ chose Aptabase and rejected PostHog. That reversal is deliberate and must be
+ recorded in `PROJECT_NOTES.md`; the rest of that spec's machinery (registry,
+ fail-closed opt-out, IPC revalidation) stays authoritative.
+- **The registry allowlist is not negotiable.** `telemetryEvents.js` stays the
+ single gate: enum-only props, `validateEvent` drops unregistered events, the
+ renderer is revalidated in main over `TELEMETRY_TRACK`. **PostHog autocapture
+ and `$pageview`-style automatic events stay off.**
+- **Standing rule of record:** any registry addition lands in `PRIVACY.md` in the
+ same change.
+- **Fail-closed opt-out is preserved byte for byte.** `userSettings.loadFailed()`
+ + `effectiveEnabled()` behavior and its regression test must still hold.
+- **EU residency and no geo.** `eu.i.posthog.com`, geoip disabled — PRIVACY.md
+ currently promises the IP is not retained.
+- **The install ID is random.** A UUID generated locally and persisted through
+ `fsSafe` (`audit-q3-reliability-recovery` rule: no state write bypasses it).
+ Never a machine ID, hostname, username, email or anything derived from them.
+ Removed on opt-out.
+- **Content rules unchanged** (`PRIVACY.md`): no file paths, no code, no project
+ names, no prompts or responses, no terminal I/O, no PII.
+- **Error tracking must be opt-in.** `PRIVACY.md` already commits: if Frame ever
+ gains the ability to *send* crash reports, it "will be a separate, **opt-in**
+ setting and will be disclosed here first". Exception payloads route through
+ `scripts/redact.js` and are stripped of absolute paths before send.
+- **No new heavy dependency in the renderer** — analytics stays in main, reached
+ over the existing `TELEMETRY_TRACK` IPC channel.
+
+## Success Criteria
+
+- When a user launches Frame with telemetry on, then every event carries a stable
+ anonymous install ID, and the PostHog dashboard reports unique users and
+ DAU/WAU/MAU rather than raw event counts.
+- When users progress through install → `project_initialized` → `spec_created` →
+ `agent_run_started`, then that funnel is queryable in PostHog with per-step
+ conversion and drop-off.
+- When a user returns days after first launch, then they appear in a retention
+ cohort.
+- When a user turns telemetry off, then no request reaches PostHog, the stored
+ install ID is deleted, and a new ID is minted only if telemetry is turned back
+ on.
+- When `user-settings.json` is corrupt or unreadable, then telemetry stays off
+ (the existing fail-closed test still passes unchanged).
+- When a call site passes an event or property outside the registry, then it is
+ dropped before the PostHog SDK is reached — asserted by tests, including the
+ renderer-over-IPC path.
+- When error tracking is off (the default), then no exception message, stack
+ frame or path leaves the machine; when a user explicitly enables it, then the
+ payload is redacted and contains no absolute path, no code and no prompt text.
+- When `PRIVACY.md` is read, then it names PostHog, EU residency, the install ID
+ and what it is not, every event in the registry, and the error-tracking opt-in
+ — with nothing listed that Frame does not actually send.
+
+## Out of Scope
+
+- Session replay — an Electron dev tool's screen is the user's own code.
+- PostHog feature flags and surveys (a later spec; the free tier includes them).
+- The local activity record, `main.log` and crash minidumps — all stay local and
+ untouched (`audit-q3-reliability-recovery`).
+- Identifying users by name, email or GitHub account.
+- Renderer error-feedback UX — owned by `audit-q3-ux-error-feedback`.
+
+## Open Questions
+
+1. **Does the default-on opt-out model survive the install ID?** Frame's
+ telemetry is default-on because it was unidentifiable. A persistent
+ pseudonymous ID changes that bargain. Options: (a) keep default-on opt-out and
+ disclose the ID prominently in the one-time notice; (b) switch analytics to
+ opt-in and accept far lower volume.
+2. **"Who is actively using Frame" — how far does *who* go?** Options:
+ (a) anonymous cohorts only, no way to ever contact or name a user;
+ (b) an optional self-identify in Settings (a user volunteers an email/handle),
+ default empty.
+3. **Aptabase cutover.** Options: (a) hard switch, accept the history break;
+ (b) dual-send for one release so counts can be reconciled.
+4. **Error detail shape.** Options: (a) adopt PostHog's error-tracking product
+ (real exceptions and stack traces, opt-in); (b) keep the category enum and add
+ a single redacted `message` field, which stays inside the current registry
+ model.
diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json
new file mode 100644
index 00000000..6d32be92
--- /dev/null
+++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json
@@ -0,0 +1,22 @@
+{
+ "slug": "posthog-analytics-identity-funnels-and-errors",
+ "title": "PostHog analytics — identity, funnels and errors",
+ "phase": "done",
+ "generated_task_ids": [
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T01",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T02",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T03",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T04",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T05",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T06",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T07",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T08",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T09",
+ "task-spec-posthog-analytics-identity-funnels-and-errors-T10"
+ ],
+ "ai_tool": "claude-code",
+ "created_at": "2026-09-21T12:43:40Z",
+ "updated_at": "2026-09-21T13:53:09Z",
+ "last_phase_at": "2026-09-21T13:53:09Z",
+ "implement_mode": "guided"
+}
diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/tasks.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/tasks.md
new file mode 100644
index 00000000..629a6ace
--- /dev/null
+++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/tasks.md
@@ -0,0 +1,12 @@
+# Tasks — PostHog analytics — identity, funnels and errors
+
+- T01 · Add `resolveInstallId({ stored, enabled })` to `src/main/telemetryEvents.js` returning `{ id, write }`, with tests for mint-when-absent, reuse-when-stored, reject-malformed, and null-plus-delete when telemetry is off
+- T02 · Replace `@aptabase/electron` with `posthog-node` in `package.json` and `src/main/telemetry.js` — client on `https://eu.i.posthog.com` with `disableGeoip: true`, `distinctId` on every `capture`, `$set` of OS and app version on the first capture of a launch, corrected module header — leaving `track()`'s gate order, `validateEvent` and the rate limiter untouched, autocapture off, and the SDK confined to the main process
+- T03 · Call `posthog.shutdown()` from the existing `before-quit` handler in `src/main/index.js` so the batcher flushes a session's final events
+- T04 · Delete the stored install ID in `setEnabled(false)` and mint a fresh one on re-enable, and extend the existing fail-closed tests in `test/telemetry.test.js` to assert no ID survives while telemetry is off
+- T05 · Add `sanitizeException({ message, stack })` to `src/main/telemetryEvents.js` — `scripts/redact.js` first, then absolute POSIX and Windows paths reduced to basenames — with tests covering a secret-bearing and a path-bearing input
+- T06 · Add the `errorReportingEnabled` setting (default `false`) and `telemetry.captureException`, gated on that setting and on `isEnabled()` and passed through the same rate limiter instance as `track()`, then wire `src/main/crashGuard.js`'s `uncaughtException` and `unhandledRejection` handlers into it
+- T07 · Add `TELEMETRY_EXCEPTION` to `src/shared/ipcChannels.js`, its `ipcMain.on` listener in `src/main/index.js` sanitizing in main, and `window.onerror` / `unhandledrejection` hooks in `src/renderer/index.js`
+- T08 · Add the error-reporting row to the Privacy & Analytics section of `index.html` and wire it in `src/renderer/frameSettingsModal.js` beside the crash-dumps toggle
+- T09 · Add `NOTICE_VERSION` and `shouldShowNotice({ storedVersion, currentVersion })` to `src/main/telemetryEvents.js` with tests for fresh install, legacy boolean-only client and already-seen current version, and read the versioned key in `src/renderer/telemetryNotice.js` with copy that names the install ID
+- T10 · Rewrite `PRIVACY.md` for PostHog, EU residency, the install ID and what it is not, the registry's eleven events unchanged, and the error-reporting opt-in — and record the reversal of `audit-q3-product-analytics`'s vendor decision as a dated entry in `.frame/PROJECT_NOTES.md`
diff --git a/.frame/tasks.json b/.frame/tasks.json
index dbb12271..283ff22c 100644
--- a/.frame/tasks.json
+++ b/.frame/tasks.json
@@ -7,7 +7,7 @@
},
"project": "Frame",
"version": "2.0",
- "lastUpdated": "2026-09-16T13:46:16.346Z",
+ "lastUpdated": "2026-09-21T13:24:03.022Z",
"taskSchema": {
"_comment": "This schema shows the expected structure for each task",
"id": "unique-id (task-xxx format)",
@@ -705,17 +705,17 @@
},
{
"id": "task-aptabase-key-env",
- "title": "Move the Aptabase app key out of source into an env var",
- "description": "src/main/telemetry.js:21 hardcodes APTABASE_APP_KEY = 'A-EU-5590504973'. Read it from process.env (e.g. FRAME_APTABASE_KEY) with the value injected at build time — electron-builder can bake it via extraMetadata or a generated config module — instead of living in the tracked source. telemetry.init() must keep working when the var is absent: no key means telemetry stays off rather than crashing or sending to a wrong endpoint. Touches src/main/telemetry.js, package.json's build config, and RELEASE.local.md (the release steps gain one more exported var).",
- "userRequest": "User said: \"aptabase key'i env var'a taşımak için task yaratalım onu sonra yapalım\"",
- "acceptanceCriteria": "No Aptabase key literal remains in tracked source; A signed build produced by the documented release flow still reports telemetry; A build with the env var unset starts normally with telemetry disabled — no crash, no request to a wrong app; RELEASE.local.md documents the new variable alongside the Apple ones",
- "notes": "Not a leak: an Aptabase app key is a client-side ingestion ID, meant to ship inside the binary, and it is already in git history and every released 2.x build. Rotating it is not the point — this is config hygiene, so the key stops being a source-code constant and can differ per environment (dev vs release). Because it is already public, this is not urgent and does not need a coordinated rotation. Prior decisions in .frame/specs/audit-q3-product-analytics/ (the 10-event registry, opt-out fails CLOSED) stay as they are; only where the key comes from changes.",
+ "title": "Move the Aptabase and PostHog keys into build-time env vars",
+ "description": "src/main/analytics.js hardcodes both ingestion keys: APTABASE_APP_KEY = 'A-EU-5590504973' and POSTHOG_API_KEY = 'phc_w4KG…'. Read each from process.env (e.g. FRAME_APTABASE_KEY, FRAME_POSTHOG_KEY) with the values injected at build time — electron-builder can bake them via extraMetadata or a generated config module — instead of living in the tracked source. A desktop app has no runtime env on the user's machine, so the values must be baked into the packaged build. analytics.init() must keep working when either var is absent: a missing key turns that provider off (the other keeps sending) rather than crashing or sending to a wrong endpoint. Touches src/main/analytics.js, package.json's build config, and RELEASE.local.md (the release steps gain the exported vars).",
+ "userRequest": "User said: \"aptabase key'i env var'a taşımak için task yaratalım onu sonra yapalım\" — extended 2026-10-05 after restoring Aptabase alongside PostHog: \"şimdilik kodda dursun ama task yaratıp atabiliriz pendinge\"",
+ "acceptanceCriteria": "No Aptabase or PostHog key literal remains in tracked source; A signed build produced by the documented release flow still reports to both Aptabase and PostHog; A build (or npm start) with the vars unset starts normally with analytics sending nothing — no crash, no request to a wrong project; With only one var set, only that provider sends; RELEASE.local.md documents the new variables alongside the Apple ones",
+ "notes": "Not a leak: both are client-side, write-only ingestion keys meant to ship inside the binary (PostHog phc_ token cannot read the dashboard or delete data), and both are already in git history and released builds. Moving them does not hide them — app.asar is extractable. The real wins: (1) forks and anyone running the public repo from source stop sending events into Frame's dashboards; (2) dev runs can use separate projects or send nothing, so dev usage stops polluting prod data. Not urgent, no rotation needed. PostHog personal/secret API keys must never enter source either way. Prior decisions in .frame/specs/audit-q3-product-analytics/ (event registry allowlist, opt-out fails CLOSED) stay as they are; only where the keys come from changes.",
"status": "pending",
"priority": "low",
"category": "refactor",
- "context": "Spotted during the v2.6.0 release audit, while checking that no key ships in the package",
+ "context": "Spotted during the v2.6.0 release audit, while checking that no key ships in the package; extended 2026-10-05 when Aptabase was restored next to PostHog (ea097e7) and the user decided to keep both keys in source for now",
"createdAt": "2026-07-28T18:29:31.226Z",
- "updatedAt": "2026-07-28T18:29:31.226Z",
+ "updatedAt": "2026-10-05T12:00:00.000Z",
"completedAt": null
},
{
@@ -8361,10 +8361,140 @@
"createdAt": "2026-09-16T13:46:16.346Z",
"updatedAt": "2026-09-16T14:01:09.621Z",
"completedAt": "2026-09-16T14:01:09.621Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T01",
+ "title": "Add `resolveInstallId({ stored, enabled })` to `src/main/telemetryEvents.js` returning `{ id, write }`, with tests for mint-when-absent, reuse-when-stored, reject-malformed, and null-plus-delete when telemetry is off",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T01",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:36:43Z",
+ "completedAt": "2026-09-21T13:36:43Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T02",
+ "title": "Replace `@aptabase/electron` with `posthog-node` in `package.json` and `src/main/telemetry.js` — client on `https://eu.i.posthog.com` with `disableGeoip: true`, `distinctId` on every `capture`, `$set` of OS and app version on the first capture of a launch, corrected module header — leaving `track()`'s gate order, `validateEvent` and the rate limiter untouched, autocapture off, and the SDK confined to the main process",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T02",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:39:07Z",
+ "completedAt": "2026-09-21T13:39:07Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T03",
+ "title": "Call `posthog.shutdown()` from the existing `before-quit` handler in `src/main/index.js` so the batcher flushes a session's final events",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T03",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:40:40Z",
+ "completedAt": "2026-09-21T13:40:40Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T04",
+ "title": "Delete the stored install ID in `setEnabled(false)` and mint a fresh one on re-enable, and extend the existing fail-closed tests in `test/telemetry.test.js` to assert no ID survives while telemetry is off",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T04",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:41:45Z",
+ "completedAt": "2026-09-21T13:41:45Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T05",
+ "title": "Add `sanitizeException({ message, stack })` to `src/main/telemetryEvents.js` — `scripts/redact.js` first, then absolute POSIX and Windows paths reduced to basenames — with tests covering a secret-bearing and a path-bearing input",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T05",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:43:24Z",
+ "completedAt": "2026-09-21T13:43:24Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T06",
+ "title": "Add the `errorReportingEnabled` setting (default `false`) and `telemetry.captureException`, gated on that setting and on `isEnabled()` and passed through the same rate limiter instance as `track()`, then wire `src/main/crashGuard.js`'s `uncaughtException` and `unhandledRejection` handlers into it",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T06",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:44:39Z",
+ "completedAt": "2026-09-21T13:44:39Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T07",
+ "title": "Add `TELEMETRY_EXCEPTION` to `src/shared/ipcChannels.js`, its `ipcMain.on` listener in `src/main/index.js` sanitizing in main, and `window.onerror` / `unhandledrejection` hooks in `src/renderer/index.js`",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T07",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:45:51Z",
+ "completedAt": "2026-09-21T13:45:51Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T08",
+ "title": "Add the error-reporting row to the Privacy & Analytics section of `index.html` and wire it in `src/renderer/frameSettingsModal.js` beside the crash-dumps toggle",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T08",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:47:32Z",
+ "completedAt": "2026-09-21T13:47:32Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T09",
+ "title": "Add `NOTICE_VERSION` and `shouldShowNotice({ storedVersion, currentVersion })` to `src/main/telemetryEvents.js` with tests for fresh install, legacy boolean-only client and already-seen current version, and read the versioned key in `src/renderer/telemetryNotice.js` with copy that names the install ID",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T09",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:50:43Z",
+ "completedAt": "2026-09-21T13:50:43Z"
+ },
+ {
+ "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T10",
+ "title": "Rewrite `PRIVACY.md` for PostHog, EU residency, the install ID and what it is not, the registry's eleven events unchanged, and the error-reporting opt-in — and record the reversal of `audit-q3-product-analytics`'s vendor decision as a dated entry in `.frame/PROJECT_NOTES.md`",
+ "description": "",
+ "source": "spec:posthog-analytics-identity-funnels-and-errors:T10",
+ "status": "completed",
+ "priority": "medium",
+ "category": "feature",
+ "context": "From spec: posthog-analytics-identity-funnels-and-errors",
+ "createdAt": "2026-09-21T13:24:03.022Z",
+ "updatedAt": "2026-09-21T13:52:51Z",
+ "completedAt": "2026-09-21T13:52:51Z"
}
],
"metadata": {
- "totalCreated": 609,
+ "totalCreated": 619,
"totalCompleted": 29
},
"categories": {
@@ -8375,4 +8505,4 @@
"test": "Testing",
"research": "Research and exploration"
}
-}
\ No newline at end of file
+}
diff --git a/PRIVACY.md b/PRIVACY.md
index e9ca8ed9..6ffeb100 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -4,7 +4,11 @@ Frame is a developer tool. We respect your privacy and try to be transparent abo
## What we collect
-When telemetry is enabled (the default — opt-out via Settings), Frame sends a small set of anonymous usage events. Every event carries only its name plus, where noted, a property whose value comes from a **fixed list** — never free text, never anything typed or generated by you. Aptabase adds the app version and operating system to each event, with no personal identifier.
+When analytics is enabled (the default — opt-out via Settings), Frame sends a small set of anonymous usage events. Every event carries only its name plus, where noted, a property that is either a value from a **fixed list** or a **plain number** (one exists: how long a session was active). Never free text, never anything typed or generated by you — a number cannot hold a file path or a prompt, and a value outside its list is dropped before sending.
+
+Each event also carries a **session ID** — a random value generated fresh at every launch and never stored, which lets events from one sitting be read together and means nothing at all once the app closes.
+
+Alongside each event go four things and nothing else: a **random install ID** (see below), your **app version**, your **operating system** (`darwin`, `win32`, `linux`), and your **approximate location** (see below).
The complete event list:
@@ -19,6 +23,13 @@ The complete event list:
| `orchestration_run_started` | — | a parallel orchestration run began |
| `plugin_toggled` | `action`: `enabled` / `disabled` | the plugin system is being used (never which plugin) |
| `ai_tool_selected` | `tool`: `claude` / `codex` / `gemini` / `custom` | the active AI tool was switched, and to which |
+| `panel_opened` | `panel`: `decisions` / `structure` / `prompts` / `activity` | which dock panels get used, and which are dead weight |
+| `implement_mode_selected` | `mode`: `step-by-step` / `guided` / `autonomous` / `custom` | how people prefer to run a spec's implementation |
+| `tour_finished` | `outcome`: `finished` / `skipped` | whether the first-run tour lands or gets dismissed |
+| `session_resumed` | — | the resume-a-session path was used (never which session) |
+| `task_completed` | — | a task was marked done (never its title, id, or project) |
+| `app_session_ended` | `active_seconds`: a number, capped at one day | how long Frame was actually on screen this session — only time a window was visible counts, so leaving it open in the background adds nothing |
+| `settings_changed` | `setting`: `analytics` / `error_reporting` / `crash_dumps` / `ui_zoom` | which setting was touched — never the value it was set to |
| `feedback_submitted` | `channel`: `github_issue` / `github_discussion` / `email` | which route carried an in-app feedback report — never its title, description, or the diagnostics shown in the form |
| `error_occurred` | `category`: a fixed list of failure types (e.g. `agent_cli_not_found`, `orch_merge_failed`) | what breaks in the wild — counts only, never error messages, stack traces, or file paths |
@@ -26,6 +37,24 @@ This list is enforced in code: events and property values outside this registry
This is enough for us to understand how many people use Frame, which features get used, whether new users activate, what fails most often, and which platforms / versions are in use.
+## The install ID
+
+Each install generates a **random ID** the first time it sends anything — a UUID, produced by your machine's random number generator and nothing else.
+
+- It is **not** derived from your hardware, your machine name, your username, your email, or anything else about you. Two installs on the same computer get different IDs.
+- It lets us tell one install from another, which is the only way to count actual users rather than launches, see whether people come back, and find where they get stuck.
+- It is stored in Frame's own settings file on your machine and sent with each event. Nothing we hold can connect it to a person.
+- **Turning analytics off deletes it.** Turning analytics back on generates a new one — the old one is not recoverable, by us or by you.
+
+## Approximate location
+
+Each event is tagged with the **country and region** it came from, worked out from the IP address of the request.
+
+- We use it to see which parts of the world Frame is used in, so documentation, defaults and support hours can follow real usage rather than guesswork.
+- It is **country and region level** — the same granularity a weather site infers. No city-block accuracy, no GPS, no street address.
+- **The IP address itself is not stored.** PostHog resolves it to a location on arrival and discards it; it is never written to the event, and we never see it.
+- Turning analytics off stops this like everything else — no event, no location.
+
## What we do **not** collect
- ❌ File paths
@@ -33,24 +62,39 @@ This is enough for us to understand how many people use Frame, which features ge
- ❌ Project names
- ❌ AI prompts or responses
- ❌ Terminal input or output
-- ❌ Email addresses or any personally identifiable information
-- ❌ IP address (Aptabase strips this before storage)
+- ❌ Email addresses, names, or any personally identifiable information
+- ❌ Your IP address — it is used to derive country and region on arrival, then discarded, never stored
+- ❌ Precise location — no GPS, no street-level or city-block accuracy
+- ❌ Screen recordings or session replay of any kind
## How to opt out
-Open **Settings** (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux) and toggle **"Send anonymous usage stats"** off. The setting takes effect immediately and persists across launches.
+Open **Settings** (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux) and toggle **"Send anonymous usage stats"** off. The setting takes effect immediately and persists across launches, and switching it off deletes the install ID at the same moment.
+
+Your opt-out is durable: if the settings file on disk is ever corrupted beyond repair, analytics **fails closed** — Frame records it as switched off, nothing is sent, and no install ID is kept. Corruption can never silently re-enable analytics.
+
+You can also dismiss the one-time notice card — that doesn't change the setting itself, only hides the card. If what we collect changes materially, the card reappears once with the new text, even if you dismissed the previous one.
+
+## Error reports (opt-in, off by default)
-Your opt-out is durable: if the settings file on disk is ever corrupted beyond repair, telemetry **fails closed** — Frame records it as switched off, and nothing is sent until you turn it back on in Settings. Corruption can never silently re-enable telemetry.
+Separately from usage stats, Frame can send **crash and exception detail** so bugs can be diagnosed instead of merely counted. This is **off unless you turn it on**, under **Settings → Privacy & Analytics → "Send error reports"**, and it also requires anonymous usage stats to be on.
-You can also dismiss the one-time notice banner at the top of the app — that doesn't change the setting itself, only hides the banner.
+When it is on, an exception's message and stack are sent after two passes:
+
+- **Secret redaction** — the same scrubbing the local log file uses: API keys (`sk-…`), GitHub/Slack tokens, AWS keys, JWTs, `Bearer` headers, and `password=`/`token=`/`api_key=` values become `[REDACTED]`.
+- **Path stripping** — every absolute path is reduced to its filename. `open '/Users/you/your-startup/src/billing.js'` is sent as `open 'billing.js'`. The filename says which file broke; nothing says who you are or what you are building.
+
+This channel never touches the event list above, and nothing on it is sent while the toggle is off.
## Where the data goes
-Telemetry is sent to [Aptabase](https://aptabase.com), an open-source, privacy-focused analytics service. They do not retain IP addresses and do not share data with third parties.
+Analytics is sent to [PostHog](https://posthog.com), an open-source product analytics service, on their **EU cloud** (`eu.i.posthog.com`). PostHog derives country and region from the request IP and discards the IP itself — see **Approximate location** above. PostHog is [self-hostable](https://posthog.com/docs/self-host) and their platform is open source, so what happens to the data is auditable rather than taken on trust.
+
+The same usage events are also sent to [Aptabase](https://aptabase.com), an open-source, privacy-focused analytics service on their EU region. Aptabase receives only the event name, its fixed-list property, the app version and the operating system — **not** the install ID, and never error reports. Aptabase does not retain IP addresses and does not share data with third parties.
## Local logging
-Frame keeps a **local** log file to make bugs debuggable — it never leaves your machine and is not part of telemetry.
+Frame keeps a **local** log file to make bugs debuggable — it never leaves your machine and is not part of analytics.
- **Location:** Electron's standard logs directory — on macOS `~/Library/Logs/Frame/main.log` (find it via **Settings → About → Open Logs Folder**).
- **Rotation:** capped at 5 MB with up to 3 archived files (`main.old.1.log` … `main.old.3.log`); old content is discarded, never uploaded.
@@ -60,7 +104,7 @@ When filing a bug report, attaching the log is the fastest way to help us help y
## Activity record (local only)
-Frame does a lot of work on its own — watching files, reconciling spec phases, rebuilding its index, recovering a corrupt state file, running hooks during a commit. The **Activity** panel shows that work, and it reads a record kept **on your machine only**. Nothing in it is transmitted, to us or anyone else; it shares no code path and no storage with telemetry, and turning telemetry off or on changes nothing about it.
+Frame does a lot of work on its own — watching files, reconciling spec phases, rebuilding its index, recovering a corrupt state file, running hooks during a commit. The **Activity** panel shows that work, and it reads a record kept **on your machine only**. Nothing in it is transmitted, to us or anyone else; it shares no code path and no storage with analytics, and turning analytics off or on changes nothing about it.
- **Location:** `~/.frame/activity//activity.jsonl`, outside your repository — Frame never edits your project's `.gitignore`, so keeping it in the repo would dirty your `git status`. Work that belongs to no project goes to an `app` bucket.
- **Retention:** the live file rotates at 2 MB into a single archived generation, and any project's record untouched for 7 days is deleted.
@@ -73,7 +117,7 @@ Frame does a lot of work on its own — watching files, reconciling spec phases,
On a crash, Frame saves a **minidump on your machine** using Electron's built-in crash reporter with uploading **disabled** — nothing is ever transmitted, to us or anyone else. This exists purely so a crash can be diagnosed locally.
- Toggle it off under **Settings → Privacy & Analytics → "Keep local crash dumps"** (takes effect on next launch).
-- If Frame ever gains the ability to *send* crash reports, that will be a separate, **opt-in** setting and will be disclosed here first.
+- This is separate from **"Send error reports"** above: minidumps stay on disk whatever that setting says.
## Questions
diff --git a/index.html b/index.html
index 5b79df75..448ca6c8 100644
--- a/index.html
+++ b/index.html
@@ -18,25 +18,27 @@
-
-
- Frame sends your app version and OS to count active users. No code or file paths.
+
+ Frame sends a random ID for this install, your app version, OS, and the country
+ your request came from, so we can count active users and see which features get
+ used. No code, file paths or prompts. Turning this off in Settings deletes the ID.
- Frame collects only your app version and OS to count active users.
+ A random ID for this install, plus your app version, OS and country, so we
+ can count active users and see which features get used.
No code, file paths, prompts, or personal data — ever.
+
+
+
+
+
+ Off by default. When on, crashes are sent with their message and stack,
+ stripped of secrets and of every path above the filename.
+ Requires anonymous usage stats to be on.
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 87cc637d..e8a04d81 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"lucide": "^0.577.0",
"marked": "^17.0.5",
"node-pty": "^1.0.0",
+ "posthog-node": "^5.52.5",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
@@ -389,6 +390,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
+ "peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -410,6 +412,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1169,6 +1172,21 @@
"node": ">=14"
}
},
+ "node_modules/@posthog/core": {
+ "version": "1.55.1",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.1.tgz",
+ "integrity": "sha512-S76bSbCHGVC8oQa1zyue6A6WzkNf1Ue4oRvfvBNLRnFM3UoWnpWnQ4T/4IzrIEtVCQYv1OI7gk8uL8/U6Tuu7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/types": "^1.412.3"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.412.4",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.412.4.tgz",
+ "integrity": "sha512-Q7lV9O9TbLngjOYw1ucm3bS3tn48xb1B4ZQdDy8gv7yfKe99hCTeWYG4xZ36nJM80VxpTF9TriiJt5hngDOkBg==",
+ "license": "MIT"
+ },
"node_modules/@profoundlogic/hogan": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz",
@@ -1396,7 +1414,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -2222,7 +2239,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
- "optional": true
+ "optional": true,
+ "peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -2565,7 +2583,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -2841,7 +2858,6 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -2953,7 +2969,6 @@
"integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==",
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^18.11.18",
@@ -3038,6 +3053,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -3058,6 +3074,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -3073,6 +3090,7 @@
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
@@ -3083,6 +3101,7 @@
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 4.0.0"
}
@@ -5092,7 +5111,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -5115,6 +5133,26 @@
"node": ">=10.4.0"
}
},
+ "node_modules/posthog-node": {
+ "version": "5.52.5",
+ "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.52.5.tgz",
+ "integrity": "sha512-r4KRXh2MvcHYh1s3NUca022GqujkJjpmD4qbRzWOLVTu8BfN3fqZj+MyKIxvHPXsS8sKsNVX+MlKk2qzW0Y4GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/core": "^1.55.1"
+ },
+ "engines": {
+ "node": "^20.20.0 || >=22.22.0"
+ },
+ "peerDependencies": {
+ "rxjs": "^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rxjs": {
+ "optional": true
+ }
+ }
+ },
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
@@ -5122,6 +5160,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -5139,6 +5178,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -5782,6 +5822,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -5808,6 +5849,7 @@
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -5829,6 +5871,7 @@
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -5842,6 +5885,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -5856,6 +5900,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -6109,8 +6154,7 @@
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
diff --git a/package.json b/package.json
index 58429c91..aabe0e95 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"lucide": "^0.577.0",
"marked": "^17.0.5",
"node-pty": "^1.0.0",
+ "posthog-node": "^5.52.5",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
diff --git a/scripts/intent-map.json b/scripts/intent-map.json
index e703fda1..0119ca26 100644
--- a/scripts/intent-map.json
+++ b/scripts/intent-map.json
@@ -1,87 +1,268 @@
{
"_comment": "Curated concept → modules map for STRUCTURE.json's intentIndex. Agent-editable: add a concept when a feature spans files whose names don't say what they do, and synonyms for the words people actually search. update-structure.js reads this first, then auto-groups only multi-file suffix groups. Module keys must match STRUCTURE.json (missing ones are skipped at generation).",
"terminal": {
- "modules": ["main/pty", "main/ptyManager", "renderer/terminal", "renderer/terminalManager", "renderer/terminalGrid", "renderer/terminalTabBar", "renderer/multiTerminalUI"],
- "synonyms": ["pty", "shell", "console", "xterm"]
+ "modules": [
+ "main/pty",
+ "main/ptyManager",
+ "renderer/terminal",
+ "renderer/terminalManager",
+ "renderer/terminalGrid",
+ "renderer/terminalTabBar",
+ "renderer/multiTerminalUI"
+ ],
+ "synonyms": [
+ "pty",
+ "shell",
+ "console",
+ "xterm"
+ ]
},
"history": {
- "modules": ["main/promptLogger", "renderer/historyPanel", "renderer/promptsPanel"],
- "synonyms": ["prompt-history", "prompts", "prompt-log"]
+ "modules": [
+ "main/promptLogger",
+ "renderer/historyPanel",
+ "renderer/promptsPanel"
+ ],
+ "synonyms": [
+ "prompt-history",
+ "prompts",
+ "prompt-log"
+ ]
},
"file-tree": {
- "modules": ["main/fileTree", "renderer/fileTreeUI"],
- "synonyms": ["files", "explorer", "file-browser"]
+ "modules": [
+ "main/fileTree",
+ "renderer/fileTreeUI"
+ ],
+ "synonyms": [
+ "files",
+ "explorer",
+ "file-browser"
+ ]
},
"ipc": {
- "modules": ["shared/ipcChannels"],
- "synonyms": ["channels", "ipc-channels", "preload"]
+ "modules": [
+ "shared/ipcChannels"
+ ],
+ "synonyms": [
+ "channels",
+ "ipc-channels",
+ "preload"
+ ]
},
"frame-config": {
- "modules": ["shared/frameConstants", "shared/frameTemplates", "main/frameProject"],
- "synonyms": ["templates", "project-init", "init", "scaffold"]
+ "modules": [
+ "shared/frameConstants",
+ "shared/frameTemplates",
+ "main/frameProject"
+ ],
+ "synonyms": [
+ "templates",
+ "project-init",
+ "init",
+ "scaffold"
+ ]
},
"sidebar": {
- "modules": ["renderer/sidebarResize", "renderer/projectListUI"],
- "synonyms": ["project-list"]
+ "modules": [
+ "renderer/sidebarResize",
+ "renderer/projectListUI"
+ ],
+ "synonyms": [
+ "project-list"
+ ]
},
"structure": {
- "modules": ["renderer/structureMap", "main/structureBootstrap"],
- "synonyms": ["structure-map", "codebase-map", "architecture", "intent-index"]
+ "modules": [
+ "renderer/structureMap",
+ "main/structureBootstrap"
+ ],
+ "synonyms": [
+ "structure-map",
+ "codebase-map",
+ "architecture",
+ "intent-index"
+ ]
},
"claude-usage": {
- "modules": ["main/claudeUsageManager"],
- "synonyms": ["usage", "quota", "rate-limit"]
+ "modules": [
+ "main/claudeUsageManager"
+ ],
+ "synonyms": [
+ "usage",
+ "quota",
+ "rate-limit"
+ ]
},
"claude-sessions": {
- "modules": ["main/claudeSessionsManager"],
- "synonyms": ["sessions"]
+ "modules": [
+ "main/claudeSessionsManager"
+ ],
+ "synonyms": [
+ "sessions"
+ ]
},
"git-branches": {
- "modules": ["main/gitBranchesManager"],
- "synonyms": ["branches", "worktree", "worktrees", "checkout"]
+ "modules": [
+ "main/gitBranchesManager"
+ ],
+ "synonyms": [
+ "branches",
+ "worktree",
+ "worktrees",
+ "checkout"
+ ]
},
"git-changes": {
- "modules": ["main/gitDiffManager", "main/gitStatusManager", "renderer/gitChangesPanel", "renderer/diffViewer", "renderer/diffSection"],
- "synonyms": ["diff", "git-status", "changes", "staged"]
+ "modules": [
+ "main/gitDiffManager",
+ "main/gitStatusManager",
+ "renderer/gitChangesPanel",
+ "renderer/diffViewer",
+ "renderer/diffSection"
+ ],
+ "synonyms": [
+ "diff",
+ "git-status",
+ "changes",
+ "staged"
+ ]
},
"ai-tool": {
- "modules": ["main/aiToolManager", "renderer/aiToolSelector"],
- "synonyms": ["auth", "cli", "codex", "gemini", "login", "agent-cli"]
+ "modules": [
+ "main/aiToolManager",
+ "renderer/aiToolSelector"
+ ],
+ "synonyms": [
+ "auth",
+ "cli",
+ "codex",
+ "gemini",
+ "login",
+ "agent-cli"
+ ]
},
"github": {
- "modules": ["main/githubManager", "renderer/githubPanel"],
- "synonyms": ["issues", "gh", "pull-requests", "pr"]
+ "modules": [
+ "main/githubManager",
+ "renderer/githubPanel"
+ ],
+ "synonyms": [
+ "issues",
+ "gh",
+ "pull-requests",
+ "pr"
+ ]
},
"orchestration": {
- "modules": ["main/orchestrationManager", "renderer/orchestrator", "renderer/homeBoard", "renderer/laneRail", "renderer/laneDetailRail", "renderer/laneStatus", "renderer/agentDispatch", "renderer/agentPanel"],
- "synonyms": ["conductor", "worker", "lanes", "orchestrator", "agents", "dispatch"]
+ "modules": [
+ "main/orchestrationManager",
+ "renderer/orchestrator",
+ "renderer/homeBoard",
+ "renderer/laneRail",
+ "renderer/laneDetailRail",
+ "renderer/laneStatus",
+ "renderer/agentDispatch",
+ "renderer/agentPanel"
+ ],
+ "synonyms": [
+ "conductor",
+ "worker",
+ "lanes",
+ "orchestrator",
+ "agents",
+ "dispatch"
+ ]
},
"specs": {
- "modules": ["main/specManager", "renderer/specPanel", "renderer/specSection", "renderer/specsDashboard", "renderer/specPanelResize"],
- "synonyms": ["spec", "spec-driven", "plan"]
+ "modules": [
+ "main/specManager",
+ "renderer/specPanel",
+ "renderer/specSection",
+ "renderer/specsDashboard",
+ "renderer/specPanelResize"
+ ],
+ "synonyms": [
+ "spec",
+ "spec-driven",
+ "plan"
+ ]
},
"tasks": {
- "modules": ["main/tasksManager", "renderer/tasksPanel", "renderer/taskSection", "renderer/tasksDashboard", "renderer/taskConfirmModal", "renderer/taskInfoModal", "renderer/taskRunModal"],
- "synonyms": ["todo", "task-list"]
+ "modules": [
+ "main/tasksManager",
+ "renderer/tasksPanel",
+ "renderer/taskSection",
+ "renderer/tasksDashboard",
+ "renderer/taskConfirmModal",
+ "renderer/taskInfoModal",
+ "renderer/taskRunModal"
+ ],
+ "synonyms": [
+ "todo",
+ "task-list"
+ ]
},
"settings": {
- "modules": ["main/userSettings", "renderer/settingsModal"],
- "synonyms": ["preferences", "config"]
+ "modules": [
+ "main/userSettings",
+ "renderer/settingsModal"
+ ],
+ "synonyms": [
+ "preferences",
+ "config"
+ ]
},
"editor": {
- "modules": ["main/fileEditor", "renderer/editor"],
- "synonyms": ["file-editor", "code-editor"]
- },
- "telemetry": {
- "modules": ["main/telemetry", "renderer/telemetryNotice"],
- "synonyms": ["analytics", "metrics"]
+ "modules": [
+ "main/fileEditor",
+ "renderer/editor"
+ ],
+ "synonyms": [
+ "file-editor",
+ "code-editor"
+ ]
},
"crash": {
- "modules": ["main/crashGuard", "renderer/healthNotice", "renderer/statusBar/noticeTray", "renderer/statusBar/noticeTrayModel"],
- "synonyms": ["crash-guard", "health", "recovery", "crash-recovery", "notices", "errors", "notification"]
+ "modules": [
+ "main/crashGuard",
+ "renderer/healthNotice",
+ "renderer/statusBar/noticeTray",
+ "renderer/statusBar/noticeTrayModel"
+ ],
+ "synonyms": [
+ "crash-guard",
+ "health",
+ "recovery",
+ "crash-recovery",
+ "notices",
+ "errors",
+ "notification"
+ ]
},
"commands": {
- "modules": ["renderer/commandPalette", "renderer/commandRegistry", "renderer/cheatSheet"],
- "synonyms": ["palette", "shortcuts", "keybindings", "command-palette"]
+ "modules": [
+ "renderer/commandPalette",
+ "renderer/commandRegistry",
+ "renderer/cheatSheet"
+ ],
+ "synonyms": [
+ "palette",
+ "shortcuts",
+ "keybindings",
+ "command-palette"
+ ]
+ },
+ "analytics": {
+ "modules": [
+ "main/analytics",
+ "renderer/analyticsNotice"
+ ],
+ "synonyms": [
+ "telemetry",
+ "metrics",
+ "posthog",
+ "usage-stats"
+ ]
}
}
diff --git a/src/main/activityLog.js b/src/main/activityLog.js
index 6d2716a2..c71f1670 100644
--- a/src/main/activityLog.js
+++ b/src/main/activityLog.js
@@ -8,7 +8,7 @@
* in-memory ring the panel reads first, enforces the rate cap, aggregates
* suppression bursts, and knows which bucket the active project writes to.
*
- * Split the way `telemetry.js` / `telemetryEvents.js` already are — policy in
+ * Split the way `analytics.js` / `analyticsEvents.js` already are — policy in
* a pure module that `node --test` can reach, Electron coupling here.
*
* The ring always fills, whatever the panel is doing. A monitor you have to
diff --git a/src/main/aiToolManager.js b/src/main/aiToolManager.js
index 35db5abc..6f5ee3d6 100644
--- a/src/main/aiToolManager.js
+++ b/src/main/aiToolManager.js
@@ -9,7 +9,7 @@ const path = require('path');
const { spawn } = require('child_process');
const fsSafe = require('./fsSafe');
const logger = require('./logger');
-const telemetry = require('./telemetry');
+const analytics = require('./analytics');
// The login shell and probe budget are shared with envPath so the CLI probe
// and the PATH repair consult the same shell and give up at the same time.
const { loginShell, PROBE_TIMEOUT_MS } = require('./envPath');
@@ -164,7 +164,7 @@ function setActiveTool(toolId) {
const changed = config.activeTool !== toolId;
config.activeTool = toolId;
saveConfig();
- if (changed) telemetry.track('ai_tool_selected', { tool: toolId });
+ if (changed) analytics.track('ai_tool_selected', { tool: toolId });
// Notify renderer about the change
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -326,7 +326,7 @@ const PROBE_FAILURE_CATEGORIES = {
function trackProbeFailure(reason) {
const category = PROBE_FAILURE_CATEGORIES[reason];
- if (category) telemetry.track('error_occurred', { category });
+ if (category) analytics.track('error_occurred', { category });
}
/**
diff --git a/src/main/analytics.js b/src/main/analytics.js
new file mode 100644
index 00000000..be61d112
--- /dev/null
+++ b/src/main/analytics.js
@@ -0,0 +1,387 @@
+/**
+ * Analytics
+ *
+ * Anonymous usage events, sent to both PostHog and Aptabase. Default opt-out: analytics runs
+ * unless the user disables it from Settings, and fails closed when the
+ * settings file is unreadable. Every event must be declared in the
+ * registry in analyticsEvents.js — event names plus low-cardinality enum
+ * props only. No file paths, no project names, no code, no free-form
+ * strings, no personally identifying information. The full event list is
+ * documented in PRIVACY.md; keep the two in sync.
+ *
+ * Events carry a stable install id (a random UUID, see resolveInstallId)
+ * so the dashboard can count unique users, follow the activation funnel
+ * and read retention cohorts. It identifies an install, never a person:
+ * nothing here can be joined to a name, an email or a machine.
+ *
+ * Aptabase receives the same validated events without the install id, the
+ * session id or the person properties — it only ever reports event counts.
+ * Exception detail goes to PostHog only.
+ *
+ * The PostHog project API key and the Aptabase app key below are public
+ * identifiers (not secrets). Both are write-only — they cannot read the
+ * dashboard or delete data. Same model as Google Analytics tracking IDs.
+ */
+
+const { PostHog } = require('posthog-node');
+const aptabase = require('@aptabase/electron/main');
+const { randomUUID } = require('node:crypto');
+const { app } = require('electron');
+const userSettings = require('./userSettings');
+const analyticsEvents = require('./analyticsEvents');
+
+// PostHog project token (Settings → Project token & ID, project 280352, EU
+// Cloud). Write-only and safe in a public app — it can send events and
+// nothing else: it cannot read the dashboard, change settings or delete
+// data. The personal and project *secret* keys can do all three and must
+// never appear here.
+const POSTHOG_API_KEY = 'phc_w4KGHkLoGyutiNQVYoXnzXJmdUKXc7WhzvGXKKt2dzez';
+// EU residency.
+const POSTHOG_HOST = 'https://eu.i.posthog.com';
+const APTABASE_APP_KEY = 'A-EU-5590504973';
+
+// Bounds the quit path: a dead network must not be able to hold the app
+// open, so the flush gets this long and the quit proceeds regardless.
+const SHUTDOWN_TIMEOUT_MS = 2000;
+
+const ENABLED_KEY = 'analyticsEnabled';
+const INSTALL_ID_KEY = 'analyticsInstallId';
+// Opt-IN, unlike ENABLED_KEY. PRIVACY.md committed, before this existed,
+// that any ability to *send* crash detail would be a separate opt-in
+// setting; absent means off, and only an explicit true turns it on.
+const ERROR_REPORTING_KEY = 'errorReportingEnabled';
+const NOTICE_VERSION_KEY = 'analyticsNoticeVersion';
+// Superseded by NOTICE_VERSION_KEY; still read so an existing install is not
+// mistaken for a fresh one. Never written again. It only ever existed under
+// the old name — it was replaced before the rename.
+const NOTICE_SHOWN_KEY = 'telemetryNoticeShown';
+
+let client = null;
+let aptabaseInitialized = false;
+let installId = null;
+let identified = false;
+// One per launch. PostHog groups events into sessions by this; posthog-node
+// forwards it but never invents one — it is a server-side SDK with no idea
+// what a session is. For Frame a launch is the session.
+const sessionId = randomUUID();
+const activeTimer = analyticsEvents.createActiveTimer();
+let activeTicker = null;
+
+// Bounds what one run can spend of the analytics quota; see the limiter's
+// note in analyticsEvents.js for why an app that only sends user-driven
+// events still needs a ceiling.
+const rateLimiter = analyticsEvents.createRateLimiter();
+
+/**
+ * Initialize Aptabase and build the PostHog client.
+ *
+ * MUST be called before app.whenReady(): the Aptabase SDK uses
+ * protocol.registerSchemesAsPrivileged internally. posthog-node has no such
+ * constraint. Each is set up independently, so one failing (or PostHog's key
+ * being unset) leaves the other sending.
+ *
+ * We always build (regardless of opt-out state) because construction has no
+ * network side-effects — events only go out when capture runs, and that path
+ * is gated by isEnabled(). Building eagerly avoids the chicken-and-egg with
+ * userSettings, which loads after app.whenReady.
+ */
+function init() {
+ initAptabase();
+ if (client) return;
+ if (!POSTHOG_API_KEY.startsWith('phc_') || POSTHOG_API_KEY.includes('REPLACE')) {
+ console.warn('Analytics: no PostHog project API key configured — nothing will be sent');
+ return;
+ }
+ try {
+ // Geo lookup ON: PostHog resolves the request IP to country/region and
+ // discards the IP itself. See PRIVACY.md for what is derived.
+ //
+ // `false` is load-bearing and must not be "cleaned up" to an omitted
+ // option. posthog-node is a *server-side* SDK, where the request IP is
+ // usually the server's, so its core defaults `disableGeoip` to TRUE
+ // (@posthog/core posthog-core-stateless.js: `options.disableGeoip ?? true`)
+ // and stamps `$geoip_disable: true` on every event. Frame is the
+ // exception the default does not expect: it runs on the user's machine,
+ // so the request IP is the user's and the lookup is meaningful.
+ client = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST, disableGeoip: false });
+ } catch (err) {
+ console.error('Analytics: PostHog init failed', err);
+ }
+}
+
+function initAptabase() {
+ if (aptabaseInitialized) return;
+ try {
+ aptabase.initialize(APTABASE_APP_KEY);
+ aptabaseInitialized = true;
+ } catch (err) {
+ console.error('Analytics: Aptabase init failed', err);
+ }
+}
+
+/**
+ * This install's distinct id, resolved on first use and cached for the run.
+ *
+ * Lazy because userSettings loads after init(): asking earlier would read an
+ * empty cache and mint an id for a user who had opted out. Returns null when
+ * analytics is off, which is also the caller's signal to send nothing.
+ */
+function distinctId() {
+ if (installId) return installId;
+ const { id, write } = analyticsEvents.resolveInstallId({
+ stored: userSettings.get(INSTALL_ID_KEY),
+ enabled: isEnabled()
+ });
+ if (write === 'set') userSettings.set(INSTALL_ID_KEY, id);
+ else if (write === 'delete') userSettings.set(INSTALL_ID_KEY, null);
+ installId = id;
+ return id;
+}
+
+/**
+ * Person properties, sent once per run alongside the first event.
+ *
+ * Operating system and app version only — exactly the two fields Aptabase
+ * attached automatically and that PRIVACY.md already discloses, so the
+ * disclosure surface does not grow with the move.
+ */
+function personProperties() {
+ if (identified) return undefined;
+ identified = true;
+ return { $set: { $os: process.platform, $app_version: app.getVersion() } };
+}
+
+/**
+ * Send a registered anonymous event. No-op if disabled or not initialized.
+ * The (name, props) pair is validated against the registry in
+ * analyticsEvents.js — unregistered events are dropped entirely, unknown
+ * props and out-of-enum values are stripped — so no call site (main or
+ * renderer via IPC) can ship content past the allowlist.
+ */
+function track(name, props) {
+ if (!isEnabled() || (!client && !aptabaseInitialized)) return;
+ const validated = analyticsEvents.validateEvent(name, props);
+ if (validated === null) return;
+ const gate = rateLimiter.check(Date.now());
+ if (gate.notice) console.warn('Analytics:', gate.notice);
+ if (!gate.allowed) return;
+ if (aptabaseInitialized) {
+ try {
+ aptabase.trackEvent(name, Object.keys(validated).length ? validated : undefined);
+ } catch (err) {
+ console.error('Analytics: Aptabase trackEvent failed', err);
+ }
+ }
+ if (!client) return;
+ const id = distinctId();
+ if (!id) return;
+ try {
+ client.capture({
+ distinctId: id,
+ event: name,
+ properties: Object.assign({ $session_id: sessionId }, validated, personProperties())
+ });
+ } catch (err) {
+ console.error('Analytics: PostHog capture failed', err);
+ }
+}
+
+/**
+ * Whether exception detail may be sent.
+ *
+ * Two gates, both required: analytics must be on at all (so an analytics
+ * opt-out silences this too, and a corrupt settings file fails closed here
+ * as well), and this setting must be explicitly true. Anything else —
+ * absent, null, a stray string — reads as off.
+ */
+function isErrorReportingEnabled() {
+ return isEnabled() && userSettings.get(ERROR_REPORTING_KEY) === true;
+}
+
+/**
+ * Send one exception, sanitized. No-op unless error reporting is explicitly on.
+ *
+ * Deliberately not an event: this never touches the registry, so
+ * validateEvent stays mechanically enum-only and a reviewer does not have
+ * to trust a call site. It does share the event rate limiter, because an
+ * exception repeating inside a render loop is exactly the shape of bug that
+ * would otherwise spend a month's quota in an afternoon.
+ *
+ * The raw error is never handed to the SDK — a reconstructed one carries
+ * only the sanitized fields, so there is no path by which the original
+ * message or stack could be read off it later.
+ */
+function captureException(err) {
+ if (!isErrorReportingEnabled() || !client) return;
+ const gate = rateLimiter.check(Date.now());
+ if (gate.notice) console.warn('Analytics:', gate.notice);
+ if (!gate.allowed) return;
+ const id = distinctId();
+ if (!id) return;
+ const safe = analyticsEvents.sanitizeException(err);
+ try {
+ const scrubbed = new Error(safe.message);
+ scrubbed.name = safe.name;
+ scrubbed.stack = safe.stack;
+ client.captureException(scrubbed, id);
+ } catch (e) {
+ console.error('Analytics: captureException failed', e);
+ }
+}
+
+/**
+ * Whether the disclosure notice is due, and the version to store when it is
+ * acknowledged.
+ *
+ * Decided here rather than in the renderer: the policy lives in
+ * analyticsEvents.js next to everything else analytics decides, and the
+ * renderer reaches main for it the way it reaches main for every other
+ * analytics call.
+ */
+function noticeState() {
+ return {
+ show: analyticsEvents.shouldShowNotice({
+ storedVersion: userSettings.get(NOTICE_VERSION_KEY),
+ legacyShown: userSettings.get(NOTICE_SHOWN_KEY)
+ }),
+ version: analyticsEvents.NOTICE_VERSION
+ };
+}
+
+/**
+ * Start counting active time.
+ *
+ * Driven by a visibility-gated interval, so it advances only while a window
+ * is actually on screen. Frame is a tool people leave open all day; counting
+ * wall-clock time from launch to quit would report a working day for someone
+ * who glanced at it twice.
+ *
+ * Called after the first window exists — gatedInterval judges visibility
+ * from the open windows, and there are none before app.whenReady.
+ */
+function startActiveTimer() {
+ if (activeTicker) return;
+ // Lazy: pollGate is wired during early boot and its own header asks that
+ // nothing drag it into a require order it did not choose.
+ const pollGate = require('./pollGate');
+ activeTicker = pollGate.gatedInterval(() => activeTimer.tick(), 60 * 1000, {
+ refreshOnShow: false
+ });
+}
+
+/**
+ * Anonymous event marking this launch.
+ */
+function trackAppStarted() {
+ track('app_started');
+}
+
+/**
+ * Toggle analytics from Settings. Persists the new state, then re-resolves
+ * the install id so the toggle takes effect on disk immediately.
+ *
+ * Turning analytics off deletes the stored id rather than parking it: an
+ * opt-out that leaves a resumable identifier behind is not an opt-out.
+ * Turning it back on therefore mints a new one, and the returning user is
+ * deliberately a new user to the dashboard — continuity is the thing the
+ * opt-out was asked to break.
+ */
+function setEnabled(enabled) {
+ const value = enabled === true;
+ userSettings.set(ENABLED_KEY, value);
+ installId = null;
+ identified = false;
+ distinctId();
+ return value;
+}
+
+/**
+ * Copy settings forward from the names this feature used when it was called
+ * "telemetry". Runs once, right after userSettings loads and before anything
+ * reads a setting.
+ *
+ * Skipped entirely when the settings file could not be read: a write would
+ * clear userSettings' `failed` flag and take fail-closed down with it, so a
+ * corrupt file would end up re-enabling analytics for someone who had opted
+ * out — the precise bug this codebase already fixed once.
+ */
+function migrateLegacySettings() {
+ if (userSettings.loadFailed()) return;
+ const snapshot = {};
+ for (const [current, legacy] of Object.entries(analyticsEvents.LEGACY_SETTING_KEYS)) {
+ snapshot[current] = userSettings.get(current);
+ snapshot[legacy] = userSettings.get(legacy);
+ }
+ for (const { key, value } of analyticsEvents.planLegacyMigration(snapshot)) {
+ userSettings.set(key, value);
+ }
+}
+
+/**
+ * Make a failed settings load's fail-closed state stick. Called right after
+ * userSettings loads.
+ *
+ * The in-memory flag alone does not hold: any later setting write (dismissing
+ * the analytics notice, which reappears because its own flag was lost too)
+ * rewrites the file from an empty cache and clears the flag, and the corrupt
+ * file has already been moved aside, so the next launch reads "no file" as a
+ * fresh install. Either way analytics came back on for someone who may have
+ * opted out. Writing the opt-out persists the only safe assumption; the user
+ * can turn it back on in Settings.
+ */
+function enforceFailClosed() {
+ if (!userSettings.loadFailed()) return;
+ userSettings.set(ENABLED_KEY, false);
+}
+
+/**
+ * Effective enabled state. Default ON when the setting has never been
+ * touched (opt-out semantics) — but fails CLOSED when the settings file
+ * could not be loaded at all, so corruption can never silently re-enable
+ * analytics for a user who opted out.
+ */
+function isEnabled() {
+ return analyticsEvents.effectiveEnabled({
+ value: userSettings.get(ENABLED_KEY),
+ loadFailed: userSettings.loadFailed(),
+ });
+}
+
+/**
+ * Flush the batcher and close the client, for the quit path.
+ *
+ * posthog-node queues events and sends them on an interval, so a session's
+ * last events would otherwise die with the process — the very events that
+ * say what a user did just before leaving. Resolves either way: a flush
+ * that cannot reach the network must not be able to hold the app open, so
+ * the SDK's own timeout bounds it and a failure is logged, not thrown.
+ */
+async function shutdown() {
+ // Last event of the session, sent before the flush that carries it. Zero
+ // active seconds is a real answer — an app that was launched and never
+ // looked at — so it is sent rather than skipped.
+ track('app_session_ended', { active_seconds: activeTimer.seconds() });
+ if (!client) return;
+ try {
+ await client.shutdown(SHUTDOWN_TIMEOUT_MS);
+ } catch (err) {
+ console.error('Analytics: shutdown failed', err);
+ } finally {
+ client = null;
+ }
+}
+
+module.exports = {
+ init,
+ track,
+ trackAppStarted,
+ startActiveTimer,
+ captureException,
+ noticeState,
+ setEnabled,
+ isEnabled,
+ isErrorReportingEnabled,
+ migrateLegacySettings,
+ enforceFailClosed,
+ shutdown
+};
diff --git a/src/main/analyticsEvents.js b/src/main/analyticsEvents.js
new file mode 100644
index 00000000..9107ae20
--- /dev/null
+++ b/src/main/analyticsEvents.js
@@ -0,0 +1,502 @@
+/**
+ * Analytics events — pure policy module
+ *
+ * Every decision analytics makes without touching Electron lives here so it
+ * can be unit-tested under `node --test`: the effective enabled/fail-closed
+ * decision, and (as of the event-registry work) the allowlist of events and
+ * property values that may ever leave the machine.
+ */
+
+const { randomUUID } = require('node:crypto');
+const { redact } = require('../../scripts/redact');
+
+/**
+ * The registry: every event Frame may ever send, with every allowed property
+ * and every allowed value. Properties are enums only — no free-form strings,
+ * so nothing sourced from user data (paths, names, prompts, messages) can
+ * pass through. Adding an event or a value here REQUIRES a matching
+ * PRIVACY.md update in the same change.
+ */
+const EVENTS = {
+ app_started: {},
+ project_initialized: {},
+ // origin: how the spec was started. `button` is set only when Frame itself
+ // staged the spec.new prompt, so it can undercount but never overcount;
+ // `conductor` reads Frame's own orchestration state. Ambiguity resolves
+ // down to `agent`, or to no property at all.
+ spec_created: { origin: ['button', 'agent', 'conductor'] },
+ spec_phase_advanced: {
+ phase: ['draft', 'specified', 'planned', 'tasks_generated', 'implementing', 'done'],
+ },
+ agent_run_started: { tool: ['claude', 'codex', 'gemini', 'custom'] },
+ orchestrator_opened: {},
+ orchestration_run_started: {},
+ plugin_toggled: { action: ['enabled', 'disabled'] },
+ ai_tool_selected: { tool: ['claude', 'codex', 'gemini', 'custom'] },
+ // Which channel actually carried a feedback report — the gh CLI, the
+ // prefilled browser page, or a mail draft. The channel is the whole event:
+ // no title, no description, no diagnostic value, and validateEvent strips
+ // anything outside the enum even if a call site tried.
+ feedback_submitted: { channel: ['github_issue', 'github_discussion', 'email'] },
+ // ── Behaviour: which surfaces and flows people actually use ──
+ //
+ // Each of these sits on a chokepoint rather than on a button, so one
+ // event with an enum covers what would otherwise be a handful of
+ // near-identical events. `panel_opened` is mounted once in dock.js and
+ // covers every dock panel; adding a panel adds a value here, not an
+ // event. The same reasoning that keeps plugin ids out of
+ // `plugin_toggled` keeps command names out of this section entirely.
+ panel_opened: { panel: ['decisions', 'structure', 'prompts', 'activity'] },
+ implement_mode_selected: { mode: ['step-by-step', 'guided', 'autonomous', 'custom'] },
+ tour_finished: { outcome: ['finished', 'skipped'] },
+ session_resumed: {},
+ task_completed: {},
+ // Which setting was touched, never its value — the value of the one that
+ // matters most is already carried by the opt-out itself. Switching
+ // analytics *off* deliberately never arrives: track() is gated on the new
+ // state, which is the correct behaviour for an opt-out.
+ settings_changed: { setting: ['analytics', 'error_reporting', 'crash_dumps', 'ui_zoom'] },
+ // The one numeric property in the registry. `active_seconds` counts only
+ // the time a window was actually on screen — for a tool that sits open in
+ // the background all day, wall-clock "app was running" would report eight
+ // hours for ten minutes of work. Capped at a day: a larger number is a
+ // clock change or a bug, not a session.
+ app_session_ended: { active_seconds: { type: 'number', max: 86400 } },
+ error_occurred: {
+ category: [
+ 'agent_cli_not_found',
+ 'agent_cli_timeout',
+ 'agent_spawn_error',
+ 'terminal_create_failed',
+ 'orch_worktree_failed',
+ 'orch_merge_failed',
+ 'orch_worker_failed',
+ 'plugin_marketplace_failed',
+ 'settings_corrupt_recovered',
+ ],
+ },
+};
+
+const BUILTIN_TOOLS = ['claude', 'codex', 'gemini'];
+// Spec/orchestration code identifies Claude as 'claude-code'; the dashboard
+// enum uses the tool manager's 'claude'.
+const TOOL_ALIASES = { 'claude-code': 'claude' };
+
+/**
+ * Collapse a tool id to the fixed dashboard enum. User-defined custom tool
+ * ids are user content and must never be sent — anything that isn't a
+ * built-in id (after aliasing) reads as 'custom'.
+ */
+function normalizeTool(id) {
+ if (typeof id !== 'string') return 'custom';
+ const mapped = TOOL_ALIASES[id] || id;
+ return BUILTIN_TOOLS.includes(mapped) ? mapped : 'custom';
+}
+
+/**
+ * Validate an (event, props) pair against the registry.
+ *
+ * Returns null for an unregistered event (caller must drop it), otherwise
+ * the subset of props that are registered for the event and carry an allowed
+ * enum value — unknown keys and out-of-enum values are silently stripped.
+ * `tool` props are normalized before the enum check so raw ids from call
+ * sites (including the renderer) can never pass through.
+ */
+function validateEvent(name, props) {
+ const schema = EVENTS[name];
+ if (!schema) return null;
+ const out = {};
+ for (const key of Object.keys(schema)) {
+ let value = props ? props[key] : undefined;
+ if (value === undefined) continue;
+ const spec = schema[key];
+ if (Array.isArray(spec)) {
+ if (key === 'tool') value = normalizeTool(value);
+ if (spec.includes(value)) out[key] = value;
+ continue;
+ }
+ // A declared numeric property. The type check is the whole guarantee:
+ // a number cannot carry a file path, a project name or a prompt, so
+ // "no content leaves the machine" survives even though "enum only"
+ // no longer describes every property. Anything not a finite number in
+ // range is dropped, exactly as an out-of-enum string would be.
+ if (spec && spec.type === 'number') {
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= spec.max) {
+ out[key] = Math.round(value);
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * Effective analytics state from the persisted setting plus settings-load
+ * health. Default ON when the setting was never touched (opt-out semantics),
+ * but a failed settings load fails CLOSED: we can no longer know whether the
+ * user opted out, so we must assume they did.
+ *
+ * @param {{ value: any, loadFailed: boolean }} state
+ * value — userSettings.get('analyticsEnabled') (null when never set)
+ * loadFailed — userSettings.loadFailed()
+ * @returns {boolean}
+ */
+function effectiveEnabled({ value, loadFailed }) {
+ if (loadFailed) return false;
+ return value !== false;
+}
+
+// ─── The disclosure notice ────────────────────────────────
+//
+// The notice used to be a boolean: shown once, dismissed forever. That was
+// honest while analytics carried no identifier, but people acknowledged a
+// system that has since changed — a stable install id now rides along. So
+// the flag became a version, and bumping it shows the new text once to
+// everyone, including the users who dismissed the old one.
+//
+// Bump NOTICE_VERSION only when the disclosure itself changes. It is not a
+// release counter, and a bump interrupts every user exactly once.
+
+const NOTICE_VERSION = 3;
+
+/**
+ * Whether the disclosure notice should be shown.
+ *
+ * @param {{ storedVersion: any, legacyShown?: any, currentVersion?: number }} state
+ * storedVersion — analyticsNoticeVersion, absent before this existed
+ * legacyShown — analyticsNoticeShown, the boolean this replaced
+ * @returns {boolean}
+ *
+ * A client that stored only the old boolean is treated as having seen
+ * version 1: it has read *a* disclosure, just not this one. A fresh install
+ * has neither and sees the current text once, like everyone else.
+ */
+function shouldShowNotice({ storedVersion, legacyShown, currentVersion }) {
+ const current = typeof currentVersion === 'number' ? currentVersion : NOTICE_VERSION;
+ const seen = Number.isInteger(storedVersion)
+ ? storedVersion
+ : (legacyShown === true ? 1 : 0);
+ return seen < current;
+}
+
+// ─── Exception sanitization ───────────────────────────────
+//
+// Exception detail travels a channel of its own — a separate opt-in
+// setting, never the event registry — because the registry's whole value
+// is that it is mechanically enum-only. This function is what makes that
+// channel safe to open.
+//
+// The stack is the tame half: its frames point at Frame's own bundle. The
+// message is where a user's tree leaks, because Node writes the offending
+// path straight into it — `ENOENT: no such file or directory, open
+// '/Users/someone/their-project/src/thing.js'`. So both halves are
+// redacted for secret shapes and then stripped of absolute paths, keeping
+// the basename: `thing.js` still says which file, and says nothing about
+// who owns it or what they were working on.
+
+// Two or more segments, so a lone "/tmp" or a URL's "/batch" is left
+// alone; the lookbehind keeps "https://host/path" from being rewritten.
+const POSIX_PATH_RE = /(?|]+){2,}/g;
+const WINDOWS_PATH_RE = /[A-Za-z]:\\(?:[^\s\\:;,()'"<>|]+\\)*[^\s\\:;,()'"<>|]*/g;
+
+function stripAbsolutePaths(text) {
+ if (typeof text !== 'string' || text.length === 0) return text;
+ return text
+ .replace(POSIX_PATH_RE, (m) => m.slice(m.lastIndexOf('/') + 1) || m)
+ .replace(WINDOWS_PATH_RE, (m) => m.slice(m.lastIndexOf('\\') + 1) || m);
+}
+
+/**
+ * Make an exception safe to send.
+ *
+ * @param {{ message?: string, stack?: string, name?: string }} err
+ * @returns {{ name: string, message: string, stack: string }}
+ *
+ * Never throws and never returns undefined fields: this runs on the error
+ * path, where a second failure would lose the first one.
+ */
+function sanitizeException(err) {
+ const src = err || {};
+ const clean = (v) => stripAbsolutePaths(redact(typeof v === 'string' ? v : ''));
+ return {
+ name: clean(src.name) || 'Error',
+ message: clean(src.message),
+ stack: clean(src.stack)
+ };
+}
+
+// ─── Active time ──────────────────────────────────────────
+//
+// Counted by ticks, not by clock arithmetic. The caller drives this from a
+// visibility-gated interval, which stops while the app is hidden — so a
+// timestamp delta across a pause would silently bill the hidden hours as
+// active. A fixed increment per tick cannot: time only accrues when a tick
+// actually fires, which is only while a window is on screen.
+//
+// The cost is granularity: up to one tick of active time is lost when the
+// app quits mid-interval. For a number read in minutes, that is noise.
+
+/**
+ * @param {{ tickMs?: number }} [options]
+ * @returns {{ tick: () => number, seconds: () => number }}
+ */
+function createActiveTimer(options) {
+ const tickMs = (options && options.tickMs) || 60 * 1000;
+ let seconds = 0;
+ return {
+ tick() {
+ seconds += tickMs / 1000;
+ return seconds;
+ },
+ seconds() {
+ return seconds;
+ }
+ };
+}
+
+// ─── Settings that outlived their name ────────────────────
+//
+// This feature shipped as "telemetry" and was renamed to "analytics". The
+// settings on disk did not rename themselves, and three of them must not
+// be lost: an opt-out silently read as "never set" would default back ON,
+// which is the exact re-opt-in bug effectiveEnabled exists to prevent; a
+// forgotten install id would split one user into two; a forgotten notice
+// version would interrupt someone who had already read the card.
+//
+// So the old keys are copied forward once and then left alone — never
+// deleted, so an older Frame build still reads its own settings.
+
+const LEGACY_SETTING_KEYS = {
+ analyticsEnabled: 'telemetryEnabled',
+ analyticsInstallId: 'telemetryInstallId',
+ analyticsNoticeVersion: 'telemetryNoticeVersion'
+};
+
+/**
+ * What the rename owes this install.
+ *
+ * @param {Record} snapshot current and legacy keys, as read
+ * @returns {Array<{key: string, value: any}>} writes to perform, in order
+ *
+ * A key already set under its new name always wins: the migration may fill
+ * a gap, never overwrite a choice made since the rename. `false` and `0`
+ * are values, not absences — only null/undefined count as unset, which is
+ * what makes an explicit opt-out survive.
+ */
+function planLegacyMigration(snapshot) {
+ const writes = [];
+ const src = snapshot || {};
+ for (const [current, legacy] of Object.entries(LEGACY_SETTING_KEYS)) {
+ if (src[current] !== undefined && src[current] !== null) continue;
+ if (src[legacy] === undefined || src[legacy] === null) continue;
+ writes.push({ key: current, value: src[legacy] });
+ }
+ return writes;
+}
+
+// ─── The install identifier ───────────────────────────────
+//
+// Frame's analytics became user-level when it moved to PostHog: counting
+// unique users, following an activation funnel and reading a retention
+// cohort all need one stable id per install. It is a random UUID and
+// nothing else — never a machine id, hostname or username, all of which
+// are stable across reinstalls and shared with every other program on the
+// box, which is exactly the linkage this avoids.
+//
+// The decision is pure so it can be tested; `analytics.js` performs the
+// write or the delete the returned `write` asks for.
+
+const INSTALL_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * Decide this launch's install id from what is stored and whether analytics
+ * is on.
+ *
+ * @param {{ stored: any, enabled: boolean }} state
+ * @param {() => string} [mint] id source; injectable so tests stay deterministic
+ * @returns {{ id: string|null, write: 'set'|'delete'|null }}
+ * id — the distinct id to send with, or null when nothing may be sent
+ * write — what the caller must persist: store `id`, delete the stored
+ * value, or nothing
+ *
+ * Analytics off deletes the stored id rather than parking it: an opt-out
+ * that leaves a resumable identifier behind is not an opt-out, and turning
+ * analytics back on deliberately mints a new one. A malformed stored value
+ * is replaced rather than sent — it can only come from a hand-edited or
+ * half-written settings file, and neither is an identity worth keeping.
+ */
+function resolveInstallId({ stored, enabled }, mint) {
+ const valid = typeof stored === 'string' && INSTALL_ID_RE.test(stored);
+
+ if (!enabled) {
+ return { id: null, write: stored === undefined || stored === null ? null : 'delete' };
+ }
+ if (valid) return { id: stored, write: null };
+ return { id: (mint || randomUUID)(), write: 'set' };
+}
+
+/**
+ * How much of the analytics quota any single run may spend.
+ *
+ * Every event here is user-driven, so a busy day is tens of events, not
+ * hundreds. These ceilings sit far above real use and exist for the case
+ * where something loops: two Frames open on one project — an older build
+ * walking spec phases backwards while this one reconciles them forward —
+ * sent hundreds of events in minutes, and a repeating `error_occurred`
+ * could do the same. The quota is finite; a bug must not be able to spend
+ * it.
+ */
+const DEFAULT_RATE_LIMIT = { perWindow: 30, perSession: 500, windowMs: 60 * 1000 };
+
+/**
+ * A rolling-window limiter. `check(now)` decides one event and returns
+ * `{ allowed, notice }`; `notice` is a message worth logging and arrives at
+ * most once per window, because one line per dropped event is its own flood.
+ *
+ * The clock is a parameter rather than `Date.now()` so the decision stays
+ * pure and testable.
+ */
+function createRateLimiter(options) {
+ const cfg = Object.assign({}, DEFAULT_RATE_LIMIT, options || {});
+ const window = [];
+ let sessionCount = 0;
+ let suppressed = 0;
+ let lastNoticeAt = null;
+
+ return {
+ check(now) {
+ while (window.length > 0 && now - window[0] >= cfg.windowMs) window.shift();
+
+ const overSession = sessionCount >= cfg.perSession;
+ const overWindow = window.length >= cfg.perWindow;
+ if (!overSession && !overWindow) {
+ window.push(now);
+ sessionCount += 1;
+ return { allowed: true, notice: null };
+ }
+
+ suppressed += 1;
+ if (lastNoticeAt !== null && now - lastNoticeAt < cfg.windowMs) {
+ return { allowed: false, notice: null };
+ }
+ lastNoticeAt = now;
+ const cause = overSession
+ ? `this run has sent ${cfg.perSession} events`
+ : `more than ${cfg.perWindow} events in a minute`;
+ return {
+ allowed: false,
+ notice: `rate limit reached (${cause}) — dropping events; ${suppressed} dropped so far`
+ };
+ },
+ stats() {
+ return { sessionCount, suppressed };
+ }
+ };
+}
+
+// ─── Spec lifecycle ───────────────────────────────────────
+//
+// `spec_created` and `spec_phase_advanced` are read off the specs watcher's
+// pushes rather than off the code paths that write status.json: agents write
+// that file themselves and Frame's reconcile rewrites it on its own, so no
+// single write path sees a spec being carried through the workflow.
+//
+// A push can show a spec.md or a later phase that nobody produced just now —
+// a git checkout or pull, a rename, a project the user was away from. Two
+// rules keep those out:
+// • a slug is judged against every look this run has had at the project, so
+// a spec that leaves and comes back (branch flip-flop, delete + rewrite)
+// is not counted again, and a phase only counts past the furthest one seen;
+// • a slug that arrives already authored, or a phase that arrives with no
+// earlier look at it, only counts when its own timestamp says it happened
+// since the previous look. A timestamp Frame cannot read counts — the
+// event is then as good as the file, which is the most it can be.
+
+const SPEC_PHASES = EVENTS.spec_phase_advanced.phase;
+// Agents stamp these fields themselves, a little before the file lands.
+const SPEC_STAMP_SKEW_MS = 10 * 60 * 1000;
+
+function stampedSince(stamp, since) {
+ // A date without a time ("2026-09-14") parses as midnight and would read as
+ // old; it says nothing about when, so it is treated as unreadable.
+ if (typeof stamp !== 'string' || !stamp.includes('T')) return true;
+ const at = Date.parse(stamp);
+ return Number.isNaN(at) || at >= since;
+}
+
+/**
+ * Compare one push of a project's specs with this run's earlier looks at it.
+ *
+ * @param {null|{seen: Map, authored: Set, at: number}} previous
+ * the state this function returned last time for the same project, or null
+ * on the first look (which only seeds: nothing on disk is backfilled)
+ * @param {Array<{slug: string, phase: string|null, authored: boolean,
+ * created_at?: string|null, last_phase_at?: string|null}>} specs
+ * @param {number} now
+ * @returns {{ state: object, created: string[], advanced: Array<{slug: string, phase: string}> }}
+ */
+function diffSpecLifecycle(previous, specs, now) {
+ const state = {
+ seen: new Map(previous ? previous.seen : []),
+ authored: new Set(previous ? previous.authored : []),
+ at: now
+ };
+ const created = [];
+ const advanced = [];
+ const since = previous ? previous.at - SPEC_STAMP_SKEW_MS : null;
+
+ for (const spec of specs) {
+ const phaseIdx = SPEC_PHASES.indexOf(spec.phase);
+ const known = state.seen.has(spec.slug);
+
+ if (spec.authored && !state.authored.has(spec.slug)) {
+ state.authored.add(spec.slug);
+ // A slug already seen without its spec.md is being authored right now.
+ if (previous && (known || stampedSince(spec.created_at, since))) created.push(spec.slug);
+ }
+
+ if (!known) {
+ state.seen.set(spec.slug, phaseIdx);
+ continue;
+ }
+ const furthest = state.seen.get(spec.slug);
+ if (phaseIdx <= furthest) continue;
+ state.seen.set(spec.slug, phaseIdx);
+ // A spec that never had a readable phase is not advancing out of one.
+ if (furthest >= 0 && stampedSince(spec.last_phase_at, since)) {
+ advanced.push({ slug: spec.slug, phase: spec.phase });
+ }
+ }
+ return { state, created, advanced };
+}
+
+/**
+ * Carry a renamed spec's history to its new slug, so the rename does not read
+ * as a spec appearing. Returns the state unchanged when it holds neither.
+ */
+function renameSpecLifecycle(state, oldSlug, newSlug) {
+ if (!state || oldSlug === newSlug) return state;
+ if (state.seen.has(oldSlug)) {
+ state.seen.set(newSlug, state.seen.get(oldSlug));
+ state.seen.delete(oldSlug);
+ }
+ if (state.authored.delete(oldSlug)) state.authored.add(newSlug);
+ return state;
+}
+
+module.exports = {
+ EVENTS,
+ normalizeTool,
+ validateEvent,
+ effectiveEnabled,
+ resolveInstallId,
+ LEGACY_SETTING_KEYS,
+ planLegacyMigration,
+ sanitizeException,
+ NOTICE_VERSION,
+ shouldShowNotice,
+ createActiveTimer,
+ createRateLimiter,
+ DEFAULT_RATE_LIMIT,
+ diffSpecLifecycle,
+ renameSpecLifecycle
+};
diff --git a/src/main/crashGuard.js b/src/main/crashGuard.js
index 91707562..f889c80a 100644
--- a/src/main/crashGuard.js
+++ b/src/main/crashGuard.js
@@ -4,7 +4,9 @@
* Before this module, a single uncaught exception killed the whole main
* process silently and renderer/child crashes went unobserved. Everything
* here logs (rotating, redacted file via logger) and degrades instead of
- * dying: uncaught errors surface as a renderer health notice, a dead
+ * dying, and — only when the user has opted in to error reporting — also
+ * sends the exception, sanitized, via analytics: uncaught errors surface
+ * as a renderer health notice, a dead
* renderer offers reload, and Electron's built-in crashReporter collects
* **local-only** minidumps (uploadToServer: false — nothing ever leaves the
* machine; see PRIVACY.md). Dump collection is optional via the Settings
@@ -13,6 +15,7 @@
const { app, dialog, crashReporter } = require('electron');
const logger = require('./logger');
+const analytics = require('./analytics');
const { IPC } = require('../shared/ipcChannels');
const userSettings = require('./userSettings');
@@ -21,12 +24,16 @@ let mainWindow = null;
function init() {
process.on('uncaughtException', (err) => {
logger.error('crash', 'uncaughtException:', err);
+ // Sanitized and gated on the opt-in setting; a no-op for everyone who
+ // has not turned error reporting on. See analytics.captureException.
+ analytics.captureException(err);
notify('uncaught-exception', `Main process error: ${err.message}`);
});
process.on('unhandledRejection', (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
logger.error('crash', 'unhandledRejection:', err);
+ analytics.captureException(err);
notify('unhandled-rejection', `Unhandled rejection: ${err.message}`);
});
diff --git a/src/main/frameProject.js b/src/main/frameProject.js
index 8f91d510..3733327c 100644
--- a/src/main/frameProject.js
+++ b/src/main/frameProject.js
@@ -541,7 +541,7 @@ function frameHookCommands() {
* surfaced via the returned summary.
*/
function installSpecHintHook(projectPath, { file = 'settings.json' } = {}) {
- // Lazy require — aiToolManager pulls telemetry; keep init's module graph flat.
+ // Lazy require — aiToolManager pulls analytics; keep init's module graph flat.
const aiToolManager = require('./aiToolManager');
const active = aiToolManager.getActiveTool();
if (!active || active.id !== 'claude') {
@@ -1632,10 +1632,10 @@ function setupIPC(ipcMain) {
}
const config = await initializeFrameProject(projectPath, projectName, { gitSharing });
- // Lazy require, same reason as aiToolManager below: telemetry pulls
- // @aptabase/electron → electron, and CI runs the suite with no
+ // Lazy require, same reason as aiToolManager above: analytics pulls
+ // posthog-node and electron, and CI runs the suite with no
// node_modules. Keep this module's load graph Electron-free.
- require('./telemetry').track('project_initialized');
+ require('./analytics').track('project_initialized');
event.sender.send(IPC.FRAME_PROJECT_INITIALIZED, {
projectPath,
config,
diff --git a/src/main/index.js b/src/main/index.js
index cfaa1637..57b35de5 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -38,7 +38,7 @@ const userSettings = require('./userSettings');
const uiZoom = require('./uiZoom');
const gitStatusManager = require('./gitStatusManager');
const gitDiffManager = require('./gitDiffManager');
-const telemetry = require('./telemetry');
+const analytics = require('./analytics');
const specManager = require('./specManager');
const orchestrationManager = require('./orchestrationManager');
const structureLifecycle = require('./structureLifecycle');
@@ -48,6 +48,7 @@ structureLifecycle.configure({ enabled: true });
let mainWindow = null;
let quitConfirmed = false;
+let analyticsFlushed = false;
/**
* Quitting (or closing the window) tears down every PTY — killing running
@@ -239,17 +240,29 @@ function setupAllIPC() {
// Orchestration — conductor-led parallel spec execution
orchestrationManager.setupIPC(ipcMain);
- // Telemetry — toggle from Settings
- ipcMain.handle(IPC.TELEMETRY_SET_ENABLED, (event, enabled) =>
- telemetry.setEnabled(enabled)
+ // Analytics — toggle from Settings
+ ipcMain.handle(IPC.ANALYTICS_SET_ENABLED, (event, enabled) =>
+ analytics.setEnabled(enabled)
);
- // Telemetry — renderer-originated events; track() validates (name, props)
+ // Analytics — renderer-originated events; track() validates (name, props)
// against the registry, so the renderer cannot bypass the allowlist
- ipcMain.on(IPC.TELEMETRY_TRACK, (event, name, props) => {
- telemetry.track(name, props);
+ ipcMain.on(IPC.ANALYTICS_TRACK, (event, name, props) => {
+ analytics.track(name, props);
});
+ // Analytics — renderer exceptions. The renderer forwards the raw shape and
+ // captureException sanitizes here, for the same reason ANALYTICS_TRACK
+ // revalidates here: the main process is the only place the guarantee can
+ // be enforced, whatever a renderer module sends.
+ ipcMain.on(IPC.ANALYTICS_EXCEPTION, (event, err) => {
+ analytics.captureException(err);
+ });
+
+ // Analytics — is the disclosure notice due? The renderer draws it; the
+ // decision stays here, beside the settings it reads.
+ ipcMain.handle(IPC.ANALYTICS_NOTICE_STATE, () => analytics.noticeState());
+
// Diagnostics — Settings "Open Logs Folder"
ipcMain.handle(IPC.GET_LOG_INFO, () => ({
logPath: logger.getLogPath(),
@@ -304,8 +317,9 @@ function init() {
// Initialize user settings (must run after app is ready so userData path resolves)
userSettings.init();
// Before anything can write a setting: an unreadable settings file must
- // leave telemetry durably off, not just off until the next write.
- telemetry.enforceFailClosed();
+ // leave analytics durably off, not just off until the next write.
+ analytics.migrateLegacySettings();
+ analytics.enforceFailClosed();
// The interface scale reads its step from user settings, and createWindow
// reads the factor from it — so it sits between the two.
uiZoom.init();
@@ -316,9 +330,10 @@ function init() {
crashGuard.init();
// Send the launch event after userSettings is loaded so the opt-out
- // check uses the correct state. Aptabase itself was initialized earlier
- // (before app.whenReady) — see app lifecycle below.
- telemetry.trackAppStarted();
+ // check uses the correct state, and so the install id resolves against
+ // the user's real choice rather than an empty cache.
+ analytics.trackAppStarted();
+ analytics.startActiveTimer();
// Setup IPC handlers
setupAllIPC();
@@ -344,11 +359,11 @@ function initModulesWithWindow(window) {
activityLog.attachWindow(window);
}
-// Aptabase MUST be initialized before app.whenReady() because the SDK
-// internally calls protocol.registerSchemesAsPrivileged, which is only
-// allowed pre-ready. Initialization itself doesn't send anything; the
-// actual app_started event is fired from init() after userSettings loads.
-telemetry.init();
+// Initialize Aptabase and build the PostHog client. Aptabase MUST run before
+// app.whenReady() because its SDK registers a privileged protocol scheme.
+// Neither sends anything here; the app_started event is fired after
+// userSettings loads, from app.whenReady below.
+analytics.init();
// App lifecycle
app.whenReady().then(() => {
@@ -376,14 +391,24 @@ app.whenReady().then(() => {
perfMonitor.mark('window-created');
});
-// Confirm-on-quit: Cmd-Q / app menu / OS shutdown with live agents.
+// Confirm-on-quit: Cmd-Q / app menu / OS shutdown with live agents, then
+// flush analytics. posthog-node batches, so a session's last events — the
+// ones that say what the user did just before leaving — would otherwise die
+// with the process. The flush borrows the same preventDefault-then-re-quit
+// shape the confirmation already uses, and analytics.shutdown() carries its
+// own timeout so a dead network cannot hold the app open.
app.on('before-quit', (e) => {
- if (quitConfirmed) return;
- if (!confirmQuitWithLiveAgents()) {
- e.preventDefault();
- return;
+ if (!quitConfirmed) {
+ if (!confirmQuitWithLiveAgents()) {
+ e.preventDefault();
+ return;
+ }
+ quitConfirmed = true;
}
- quitConfirmed = true;
+ if (analyticsFlushed) return;
+ analyticsFlushed = true;
+ e.preventDefault();
+ analytics.shutdown().finally(() => app.quit());
});
// Structure lifecycle workers (STR-02) stop once quitting is certain —
diff --git a/src/main/orchestrationManager.js b/src/main/orchestrationManager.js
index ba50a6cc..b0466c5b 100644
--- a/src/main/orchestrationManager.js
+++ b/src/main/orchestrationManager.js
@@ -42,7 +42,7 @@ const { getOrchBinScripts } = require('../shared/frameTemplates');
const ptyManager = require('./ptyManager');
const gitBranches = require('./gitBranchesManager');
const specManager = require('./specManager');
-const telemetry = require('./telemetry');
+const analytics = require('./analytics');
const pollGate = require('./pollGate');
const WORKER_TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'orchestration', 'WORKER.md');
@@ -381,7 +381,7 @@ async function handleDispatch(session, slug) {
// Create the isolated worktree (fresh base from current HEAD)
const wt = await gitBranches.createOrchWorktree(session.projectPath, slug);
if (wt.error) {
- telemetry.track('error_occurred', { category: 'orch_worktree_failed' });
+ analytics.track('error_occurred', { category: 'orch_worktree_failed' });
relayToConductor(session, `DISPATCH FAILED: "${slug}" — worktree error: ${wt.error}`);
return;
}
@@ -471,7 +471,7 @@ async function handleMerge(session, slug, args = []) {
const res = await gitBranches.mergeWorkToIntegration(session.projectPath, slug);
if (res.error) {
- telemetry.track('error_occurred', { category: 'orch_merge_failed' });
+ analytics.track('error_occurred', { category: 'orch_merge_failed' });
relayToConductor(session, `MERGE FAILED: "${slug}" — ${res.error}`);
return { status: 'failed', error: res.error };
}
@@ -533,7 +533,7 @@ function pollStatuses(session) {
// A lane Frame closed itself (user closed it, reload, removal) is not a
// worker failure — only count one that died on its own.
if (!ptyManager.wasDestroyedOnRequest(w.terminalId)) {
- telemetry.track('error_occurred', { category: 'orch_worker_failed' });
+ analytics.track('error_occurred', { category: 'orch_worker_failed' });
}
relayToConductor(session, `WORKER FAILED: "${w.slug}" — its lane exited unexpectedly.`);
changed = true;
@@ -618,7 +618,7 @@ async function startOrchestration(args = {}) {
console.error('[orch] failed to materialize CONDUCTOR.md:', e.message);
}
- telemetry.track('orchestration_run_started');
+ analytics.track('orchestration_run_started');
startBusWatcher(session);
session.statusPoll = pollGate.gatedInterval(() => pollStatuses(session), STATUS_POLL_MS);
// Recover any worktrees/branches left by a prior (e.g. app-closed) session so
diff --git a/src/main/pluginsManager.js b/src/main/pluginsManager.js
index 65ca182e..92108608 100644
--- a/src/main/pluginsManager.js
+++ b/src/main/pluginsManager.js
@@ -8,7 +8,7 @@ const path = require('path');
const os = require('os');
const { execFile } = require('child_process');
const { IPC } = require('../shared/ipcChannels');
-const telemetry = require('./telemetry');
+const analytics = require('./analytics');
/** Run a git command async; rejects with stderr attached for classification. */
function execGit(args, opts = {}) {
@@ -199,7 +199,7 @@ function togglePlugin(pluginId) {
const success = writeJsonFile(SETTINGS_FILE, settings);
if (success) {
- telemetry.track('plugin_toggled', { action: !currentStatus ? 'enabled' : 'disabled' });
+ analytics.track('plugin_toggled', { action: !currentStatus ? 'enabled' : 'disabled' });
}
return {
@@ -222,7 +222,7 @@ let marketplaceFailureTracked = false;
function trackMarketplaceFailure() {
if (marketplaceFailureTracked) return;
marketplaceFailureTracked = true;
- telemetry.track('error_occurred', { category: 'plugin_marketplace_failed' });
+ analytics.track('error_occurred', { category: 'plugin_marketplace_failed' });
}
/** Classify a git failure into an actionable reason */
diff --git a/src/main/ptyManager.js b/src/main/ptyManager.js
index df612ef1..0620602d 100644
--- a/src/main/ptyManager.js
+++ b/src/main/ptyManager.js
@@ -7,7 +7,7 @@ const pty = require('node-pty');
const { IPC } = require('../shared/ipcChannels');
const logger = require('./logger');
const promptLogger = require('./promptLogger');
-const telemetry = require('./telemetry');
+const analytics = require('./analytics');
const pollGate = require('./pollGate');
// Store multiple PTY instances
@@ -487,7 +487,7 @@ function setupIPC(ipcMain) {
const terminalId = createTerminal(workingDir, projectPath, shellPath, extraEnv);
event.reply(IPC.TERMINAL_CREATED, { terminalId, success: true });
} catch (error) {
- telemetry.track('error_occurred', { category: 'terminal_create_failed' });
+ analytics.track('error_occurred', { category: 'terminal_create_failed' });
event.reply(IPC.TERMINAL_CREATED, { success: false, error: error.message });
}
});
diff --git a/src/main/specManager.js b/src/main/specManager.js
index fc402d1f..05add9e3 100644
--- a/src/main/specManager.js
+++ b/src/main/specManager.js
@@ -20,8 +20,8 @@ const frameStore = require('./frameStore');
const tasksManager = require('./tasksManager');
const commandStaging = require('./commandStaging');
const frameProject = require('./frameProject');
-const telemetry = require('./telemetry');
-const { diffSpecLifecycle, renameSpecLifecycle } = require('./telemetryEvents');
+const analytics = require('./analytics');
+const { diffSpecLifecycle, renameSpecLifecycle } = require('./analyticsEvents');
const activityLog = require('./activityLog');
const perfMonitor = require('./perfMonitor');
@@ -817,7 +817,7 @@ function buildSpecCommandFile(projectPath, slug, command, aiTool, description) {
fs.mkdirSync(promptsDir, { recursive: true });
const slugless = isSlugless(slug, command);
// Staging a slug-less spec.new prompt is the one thing only Frame's own New
- // Spec launcher does — it is the origin signal for the telemetry below.
+ // Spec launcher does — it is the origin signal for the analytics below.
if (slugless) markSpecNewLaunch(projectPath);
const filename = slugless
? uniquePromptFilename(promptsDir, specNewPromptFilename())
@@ -1382,7 +1382,7 @@ function stopWatching() {
// `spec_phase_advanced` is read off the same push, because agents write
// status.json themselves and no Frame write path ever saw their phases move.
// The comparison — and what keeps checkouts, renames and flip-flops out of it
-// — lives in telemetryEvents.diffSpecLifecycle.
+// — lives in analyticsEvents.diffSpecLifecycle.
//
// projectPath → the state diffSpecLifecycle returned last. Absent = no look
// yet this run: the first push seeds from what is on disk, nothing is
@@ -1451,10 +1451,10 @@ function trackSpecLifecycle(projectPath, specs) {
const attributable = created.length === 1;
for (const _slug of created) {
const origin = attributable ? resolveSpecOrigin(projectPath) : null;
- telemetry.track('spec_created', origin ? { origin } : undefined);
+ analytics.track('spec_created', origin ? { origin } : undefined);
}
for (const { phase } of advanced) {
- telemetry.track('spec_phase_advanced', { phase });
+ analytics.track('spec_phase_advanced', { phase });
}
}
diff --git a/src/main/tasksManager.js b/src/main/tasksManager.js
index 7c04bc61..b9fcc0c8 100644
--- a/src/main/tasksManager.js
+++ b/src/main/tasksManager.js
@@ -236,6 +236,13 @@ function updateTask(projectPath, taskId, updates) {
task.completedAt = new Date().toISOString();
tasksData.metadata = tasksData.metadata || {};
tasksData.metadata.totalCompleted = (tasksData.metadata.totalCompleted || 0) + 1;
+ // Lazy require, same reason as frameProject: analytics pulls
+ // posthog-node and electron, and CI runs the suite with no
+ // node_modules. Keep this module's load graph Electron-free.
+ // Nothing about the task is sent — not its title, not its id.
+ try {
+ require('./analytics').track('task_completed');
+ } catch (_) { /* analytics is never allowed to break a task write */ }
}
}
diff --git a/src/main/telemetry.js b/src/main/telemetry.js
deleted file mode 100644
index a438d8c4..00000000
--- a/src/main/telemetry.js
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * Telemetry
- *
- * Anonymous usage events via Aptabase. Default opt-out: telemetry runs
- * unless the user disables it from Settings, and fails closed when the
- * settings file is unreadable. Every event must be declared in the
- * registry in telemetryEvents.js — event names plus low-cardinality enum
- * props only. No file paths, no project names, no code, no free-form
- * strings, no personally identifying information. The full event list is
- * documented in PRIVACY.md; keep the two in sync.
- *
- * The Aptabase app key below is a public identifier (not a secret). It
- * has write-only permissions and cannot read the dashboard or delete
- * data. Same model as Google Analytics tracking IDs.
- */
-
-const aptabase = require('@aptabase/electron/main');
-const userSettings = require('./userSettings');
-const telemetryEvents = require('./telemetryEvents');
-
-const APTABASE_APP_KEY = 'A-EU-5590504973';
-const ENABLED_KEY = 'telemetryEnabled';
-
-let initialized = false;
-
-// Bounds what one run can spend of the analytics quota; see the limiter's
-// note in telemetryEvents.js for why an app that only sends user-driven
-// events still needs a ceiling.
-const rateLimiter = telemetryEvents.createRateLimiter();
-
-/**
- * Initialize Aptabase. MUST be called before app.whenReady() because the
- * SDK uses protocol.registerSchemesAsPrivileged internally.
- *
- * We always initialize (regardless of opt-out state) because the call has
- * no network side-effects on its own — events only go out when trackEvent
- * runs, and that path is gated by isEnabled(). Initializing eagerly avoids
- * the chicken-and-egg with userSettings (which loads after app.whenReady).
- */
-function init() {
- if (initialized) return;
- try {
- aptabase.initialize(APTABASE_APP_KEY);
- initialized = true;
- } catch (err) {
- console.error('Telemetry: Aptabase init failed', err);
- }
-}
-
-/**
- * Send a registered anonymous event. No-op if disabled or not initialized.
- * The (name, props) pair is validated against the registry in
- * telemetryEvents.js — unregistered events are dropped entirely, unknown
- * props and out-of-enum values are stripped — so no call site (main or
- * renderer via IPC) can ship content past the allowlist.
- */
-function track(name, props) {
- if (!isEnabled() || !initialized) return;
- const validated = telemetryEvents.validateEvent(name, props);
- if (validated === null) return;
- const gate = rateLimiter.check(Date.now());
- if (gate.notice) console.warn('Telemetry:', gate.notice);
- if (!gate.allowed) return;
- try {
- aptabase.trackEvent(name, Object.keys(validated).length ? validated : undefined);
- } catch (err) {
- console.error('Telemetry: trackEvent failed', err);
- }
-}
-
-/**
- * Anonymous event marking this launch.
- */
-function trackAppStarted() {
- track('app_started');
-}
-
-/**
- * Toggle telemetry from Settings. Persists the new state. Aptabase is
- * already initialized on boot; flipping this flag just gates trackEvent.
- */
-function setEnabled(enabled) {
- const value = enabled === true;
- userSettings.set(ENABLED_KEY, value);
- return value;
-}
-
-/**
- * Make a failed settings load's fail-closed state stick. Called right after
- * userSettings loads.
- *
- * The in-memory flag alone does not hold: any later setting write (dismissing
- * the telemetry notice, which reappears because its own flag was lost too)
- * rewrites the file from an empty cache and clears the flag, and the corrupt
- * file has already been moved aside, so the next launch reads "no file" as a
- * fresh install. Either way telemetry came back on for someone who may have
- * opted out. Writing the opt-out persists the only safe assumption; the user
- * can turn it back on in Settings.
- */
-function enforceFailClosed() {
- if (!userSettings.loadFailed()) return;
- userSettings.set(ENABLED_KEY, false);
-}
-
-/**
- * Effective enabled state. Default ON when the setting has never been
- * touched (opt-out semantics) — but fails CLOSED when the settings file
- * could not be loaded at all, so corruption can never silently re-enable
- * telemetry for a user who opted out.
- */
-function isEnabled() {
- return telemetryEvents.effectiveEnabled({
- value: userSettings.get(ENABLED_KEY),
- loadFailed: userSettings.loadFailed(),
- });
-}
-
-module.exports = { init, track, trackAppStarted, setEnabled, isEnabled, enforceFailClosed };
diff --git a/src/main/telemetryEvents.js b/src/main/telemetryEvents.js
deleted file mode 100644
index 27b66f6e..00000000
--- a/src/main/telemetryEvents.js
+++ /dev/null
@@ -1,265 +0,0 @@
-/**
- * Telemetry events — pure policy module
- *
- * Every decision telemetry makes without touching Electron lives here so it
- * can be unit-tested under `node --test`: the effective enabled/fail-closed
- * decision, and (as of the event-registry work) the allowlist of events and
- * property values that may ever leave the machine.
- */
-
-/**
- * The registry: every event Frame may ever send, with every allowed property
- * and every allowed value. Properties are enums only — no free-form strings,
- * so nothing sourced from user data (paths, names, prompts, messages) can
- * pass through. Adding an event or a value here REQUIRES a matching
- * PRIVACY.md update in the same change.
- */
-const EVENTS = {
- app_started: {},
- project_initialized: {},
- // origin: how the spec was started. `button` is set only when Frame itself
- // staged the spec.new prompt, so it can undercount but never overcount;
- // `conductor` reads Frame's own orchestration state. Ambiguity resolves
- // down to `agent`, or to no property at all.
- spec_created: { origin: ['button', 'agent', 'conductor'] },
- spec_phase_advanced: {
- phase: ['draft', 'specified', 'planned', 'tasks_generated', 'implementing', 'done'],
- },
- agent_run_started: { tool: ['claude', 'codex', 'gemini', 'custom'] },
- orchestrator_opened: {},
- orchestration_run_started: {},
- plugin_toggled: { action: ['enabled', 'disabled'] },
- ai_tool_selected: { tool: ['claude', 'codex', 'gemini', 'custom'] },
- // Which channel actually carried a feedback report — the gh CLI, the
- // prefilled browser page, or a mail draft. The channel is the whole event:
- // no title, no description, no diagnostic value, and validateEvent strips
- // anything outside the enum even if a call site tried.
- feedback_submitted: { channel: ['github_issue', 'github_discussion', 'email'] },
- error_occurred: {
- category: [
- 'agent_cli_not_found',
- 'agent_cli_timeout',
- 'agent_spawn_error',
- 'terminal_create_failed',
- 'orch_worktree_failed',
- 'orch_merge_failed',
- 'orch_worker_failed',
- 'plugin_marketplace_failed',
- 'settings_corrupt_recovered',
- ],
- },
-};
-
-const BUILTIN_TOOLS = ['claude', 'codex', 'gemini'];
-// Spec/orchestration code identifies Claude as 'claude-code'; the dashboard
-// enum uses the tool manager's 'claude'.
-const TOOL_ALIASES = { 'claude-code': 'claude' };
-
-/**
- * Collapse a tool id to the fixed dashboard enum. User-defined custom tool
- * ids are user content and must never be sent — anything that isn't a
- * built-in id (after aliasing) reads as 'custom'.
- */
-function normalizeTool(id) {
- if (typeof id !== 'string') return 'custom';
- const mapped = TOOL_ALIASES[id] || id;
- return BUILTIN_TOOLS.includes(mapped) ? mapped : 'custom';
-}
-
-/**
- * Validate an (event, props) pair against the registry.
- *
- * Returns null for an unregistered event (caller must drop it), otherwise
- * the subset of props that are registered for the event and carry an allowed
- * enum value — unknown keys and out-of-enum values are silently stripped.
- * `tool` props are normalized before the enum check so raw ids from call
- * sites (including the renderer) can never pass through.
- */
-function validateEvent(name, props) {
- const schema = EVENTS[name];
- if (!schema) return null;
- const out = {};
- for (const key of Object.keys(schema)) {
- let value = props ? props[key] : undefined;
- if (value === undefined) continue;
- if (key === 'tool') value = normalizeTool(value);
- if (schema[key].includes(value)) out[key] = value;
- }
- return out;
-}
-
-/**
- * Effective telemetry state from the persisted setting plus settings-load
- * health. Default ON when the setting was never touched (opt-out semantics),
- * but a failed settings load fails CLOSED: we can no longer know whether the
- * user opted out, so we must assume they did.
- *
- * @param {{ value: any, loadFailed: boolean }} state
- * value — userSettings.get('telemetryEnabled') (null when never set)
- * loadFailed — userSettings.loadFailed()
- * @returns {boolean}
- */
-function effectiveEnabled({ value, loadFailed }) {
- if (loadFailed) return false;
- return value !== false;
-}
-
-/**
- * How much of the analytics quota any single run may spend.
- *
- * Every event here is user-driven, so a busy day is tens of events, not
- * hundreds. These ceilings sit far above real use and exist for the case
- * where something loops: two Frames open on one project — an older build
- * walking spec phases backwards while this one reconciles them forward —
- * sent hundreds of events in minutes, and a repeating `error_occurred`
- * could do the same. The quota is finite; a bug must not be able to spend
- * it.
- */
-const DEFAULT_RATE_LIMIT = { perWindow: 30, perSession: 500, windowMs: 60 * 1000 };
-
-/**
- * A rolling-window limiter. `check(now)` decides one event and returns
- * `{ allowed, notice }`; `notice` is a message worth logging and arrives at
- * most once per window, because one line per dropped event is its own flood.
- *
- * The clock is a parameter rather than `Date.now()` so the decision stays
- * pure and testable.
- */
-function createRateLimiter(options) {
- const cfg = Object.assign({}, DEFAULT_RATE_LIMIT, options || {});
- const window = [];
- let sessionCount = 0;
- let suppressed = 0;
- let lastNoticeAt = null;
-
- return {
- check(now) {
- while (window.length > 0 && now - window[0] >= cfg.windowMs) window.shift();
-
- const overSession = sessionCount >= cfg.perSession;
- const overWindow = window.length >= cfg.perWindow;
- if (!overSession && !overWindow) {
- window.push(now);
- sessionCount += 1;
- return { allowed: true, notice: null };
- }
-
- suppressed += 1;
- if (lastNoticeAt !== null && now - lastNoticeAt < cfg.windowMs) {
- return { allowed: false, notice: null };
- }
- lastNoticeAt = now;
- const cause = overSession
- ? `this run has sent ${cfg.perSession} events`
- : `more than ${cfg.perWindow} events in a minute`;
- return {
- allowed: false,
- notice: `rate limit reached (${cause}) — dropping events; ${suppressed} dropped so far`
- };
- },
- stats() {
- return { sessionCount, suppressed };
- }
- };
-}
-
-// ─── Spec lifecycle ───────────────────────────────────────
-//
-// `spec_created` and `spec_phase_advanced` are read off the specs watcher's
-// pushes rather than off the code paths that write status.json: agents write
-// that file themselves and Frame's reconcile rewrites it on its own, so no
-// single write path sees a spec being carried through the workflow.
-//
-// A push can show a spec.md or a later phase that nobody produced just now —
-// a git checkout or pull, a rename, a project the user was away from. Two
-// rules keep those out:
-// • a slug is judged against every look this run has had at the project, so
-// a spec that leaves and comes back (branch flip-flop, delete + rewrite)
-// is not counted again, and a phase only counts past the furthest one seen;
-// • a slug that arrives already authored, or a phase that arrives with no
-// earlier look at it, only counts when its own timestamp says it happened
-// since the previous look. A timestamp Frame cannot read counts — the
-// event is then as good as the file, which is the most it can be.
-
-const SPEC_PHASES = EVENTS.spec_phase_advanced.phase;
-// Agents stamp these fields themselves, a little before the file lands.
-const SPEC_STAMP_SKEW_MS = 10 * 60 * 1000;
-
-function stampedSince(stamp, since) {
- // A date without a time ("2026-09-14") parses as midnight and would read as
- // old; it says nothing about when, so it is treated as unreadable.
- if (typeof stamp !== 'string' || !stamp.includes('T')) return true;
- const at = Date.parse(stamp);
- return Number.isNaN(at) || at >= since;
-}
-
-/**
- * Compare one push of a project's specs with this run's earlier looks at it.
- *
- * @param {null|{seen: Map, authored: Set, at: number}} previous
- * the state this function returned last time for the same project, or null
- * on the first look (which only seeds: nothing on disk is backfilled)
- * @param {Array<{slug: string, phase: string|null, authored: boolean,
- * created_at?: string|null, last_phase_at?: string|null}>} specs
- * @param {number} now
- * @returns {{ state: object, created: string[], advanced: Array<{slug: string, phase: string}> }}
- */
-function diffSpecLifecycle(previous, specs, now) {
- const state = {
- seen: new Map(previous ? previous.seen : []),
- authored: new Set(previous ? previous.authored : []),
- at: now
- };
- const created = [];
- const advanced = [];
- const since = previous ? previous.at - SPEC_STAMP_SKEW_MS : null;
-
- for (const spec of specs) {
- const phaseIdx = SPEC_PHASES.indexOf(spec.phase);
- const known = state.seen.has(spec.slug);
-
- if (spec.authored && !state.authored.has(spec.slug)) {
- state.authored.add(spec.slug);
- // A slug already seen without its spec.md is being authored right now.
- if (previous && (known || stampedSince(spec.created_at, since))) created.push(spec.slug);
- }
-
- if (!known) {
- state.seen.set(spec.slug, phaseIdx);
- continue;
- }
- const furthest = state.seen.get(spec.slug);
- if (phaseIdx <= furthest) continue;
- state.seen.set(spec.slug, phaseIdx);
- // A spec that never had a readable phase is not advancing out of one.
- if (furthest >= 0 && stampedSince(spec.last_phase_at, since)) {
- advanced.push({ slug: spec.slug, phase: spec.phase });
- }
- }
- return { state, created, advanced };
-}
-
-/**
- * Carry a renamed spec's history to its new slug, so the rename does not read
- * as a spec appearing. Returns the state unchanged when it holds neither.
- */
-function renameSpecLifecycle(state, oldSlug, newSlug) {
- if (!state || oldSlug === newSlug) return state;
- if (state.seen.has(oldSlug)) {
- state.seen.set(newSlug, state.seen.get(oldSlug));
- state.seen.delete(oldSlug);
- }
- if (state.authored.delete(oldSlug)) state.authored.add(newSlug);
- return state;
-}
-
-module.exports = {
- EVENTS,
- normalizeTool,
- validateEvent,
- effectiveEnabled,
- createRateLimiter,
- DEFAULT_RATE_LIMIT,
- diffSpecLifecycle,
- renameSpecLifecycle
-};
diff --git a/src/main/userSettings.js b/src/main/userSettings.js
index 1af7b150..ef54e24c 100644
--- a/src/main/userSettings.js
+++ b/src/main/userSettings.js
@@ -28,11 +28,11 @@ function load() {
const { data, source, error } = fsSafe.readJsonWithRecovery(settingsPath);
if (source === 'bak') {
console.error('userSettings: user-settings.json was corrupt — restored from .bak');
- // Count the recovery. Lazy + deferred require: telemetry requires this
+ // Count the recovery. Lazy + deferred require: analytics requires this
// module, so a top-level require here would be circular.
setImmediate(() => {
try {
- require('./telemetry').track('error_occurred', { category: 'settings_corrupt_recovered' });
+ require('./analytics').track('error_occurred', { category: 'settings_corrupt_recovered' });
} catch (e) {}
});
} else if (error) {
@@ -40,7 +40,7 @@ function load() {
}
// Unrecoverable load (read/parse error with no .bak to fall back on) means
// the cache no longer reflects what the user chose — consumers that must
- // not fail open (telemetry opt-out) check loadFailed(). A missing file
+ // not fail open (analytics opt-out) check loadFailed(). A missing file
// (fresh install) or a successful .bak recovery is not a failure.
failed = data === null && error !== null;
cache = data || {};
diff --git a/src/renderer/agentDispatch.js b/src/renderer/agentDispatch.js
index d40b6ba9..5012670c 100644
--- a/src/renderer/agentDispatch.js
+++ b/src/renderer/agentDispatch.js
@@ -200,7 +200,7 @@ async function dispatch({ terminalId = null, createNew = false, toolId = null, p
// The CLI actually launched and reached ready — that is an agent run.
// Main normalizes the tool id to the registry enum before sending.
- ipcRenderer.send(IPC.TELEMETRY_TRACK, 'agent_run_started', { tool: chosenToolId });
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'agent_run_started', { tool: chosenToolId });
// This dispatch started the session. Record whether it carried the
// autonomous permission flags and the CLI kept them (didn't come up
@@ -301,7 +301,7 @@ function _startAgentIn(terminalId, { fresh = false } = {}) {
// "send" and "listen". A custom CLI is never recognized as an agent by
// laneStatus, so it cannot be seen coming up and counts on launch.
function _trackAgentRunWhenReady(terminalId, toolId) {
- const track = () => ipcRenderer.send(IPC.TELEMETRY_TRACK, 'agent_run_started', { tool: toolId });
+ const track = () => ipcRenderer.send(IPC.ANALYTICS_TRACK, 'agent_run_started', { tool: toolId });
if (!laneStatus.KNOWN_AGENTS.has(toolId)) {
track();
return;
@@ -528,6 +528,11 @@ async function _dispatchImplement({ slug, title, projectPath, assignment }) {
return { success: false, terminalId: null, error: null };
}
+ // Which mode people actually pick is a product question the spec flow
+ // cannot answer from the filesystem: autonomous runs are launched
+ // elsewhere, so status.json alone under-counts them.
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'implement_mode_selected', { mode: choice.mode });
+
// Record the chosen mode *before* staging so the flags follow the choice.
try {
const res = await ipcRenderer.invoke(IPC.UPDATE_SPEC_STATUS, {
diff --git a/src/renderer/analyticsNotice.js b/src/renderer/analyticsNotice.js
new file mode 100644
index 00000000..a5372c06
--- /dev/null
+++ b/src/renderer/analyticsNotice.js
@@ -0,0 +1,75 @@
+/**
+ * Analytics Notice Banner
+ *
+ * Card in the bottom-right corner, above the status bar, carrying the
+ * analytics disclosure. Independent of the onboarding screen so users who
+ * never see that screen still get it.
+ *
+ * Versioned rather than once-and-done: people acknowledged a system that
+ * carried no identifier, and it now sends a random install id. Bumping
+ * NOTICE_VERSION (analyticsEvents.js) shows the changed text once to
+ * everyone, including users who dismissed the previous one.
+ *
+ * This module draws and dismisses; whether the notice is due is main's call
+ * (analytics.noticeState), so the policy sits with the settings it reads.
+ */
+
+const { ipcRenderer } = require('electron');
+const { IPC } = require('../shared/ipcChannels');
+
+const NOTICE_VERSION_KEY = 'analyticsNoticeVersion';
+
+// The version main said was current when it decided the notice was due —
+// stored on dismiss, so acknowledging records exactly what was read.
+let noticeVersion = null;
+
+let bannerEl = null;
+let acknowledgeBtn = null;
+let closeBtn = null;
+let settingsLink = null;
+
+async function init(openSettings) {
+ bannerEl = document.getElementById('analytics-notice');
+ acknowledgeBtn = document.getElementById('analytics-notice-ack');
+ closeBtn = document.getElementById('analytics-notice-close');
+ settingsLink = document.getElementById('analytics-notice-settings-link');
+
+ if (!bannerEl) return;
+
+ // Show only if this version of the disclosure hasn't been seen. Main
+ // decides — see analytics.noticeState.
+ const { show, version } = await ipcRenderer.invoke(IPC.ANALYTICS_NOTICE_STATE);
+ if (!show) {
+ bannerEl.remove();
+ return;
+ }
+ noticeVersion = version;
+
+ bannerEl.classList.add('visible');
+
+ if (acknowledgeBtn) acknowledgeBtn.addEventListener('click', dismiss);
+ if (closeBtn) closeBtn.addEventListener('click', dismiss);
+ if (settingsLink && typeof openSettings === 'function') {
+ settingsLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ openSettings();
+ });
+ }
+}
+
+function dismiss() {
+ if (!bannerEl) return;
+ ipcRenderer
+ .invoke(IPC.SET_USER_SETTING, NOTICE_VERSION_KEY, noticeVersion)
+ .catch((err) =>
+ console.error('Analytics notice: failed to persist dismiss', err)
+ );
+ bannerEl.classList.remove('visible');
+ // Remove from DOM after fade so layout reflows
+ setTimeout(() => {
+ if (bannerEl && bannerEl.parentNode) bannerEl.parentNode.removeChild(bannerEl);
+ bannerEl = null;
+ }, 220);
+}
+
+module.exports = { init };
diff --git a/src/renderer/dock.js b/src/renderer/dock.js
index 1cb5f63e..823e122d 100644
--- a/src/renderer/dock.js
+++ b/src/renderer/dock.js
@@ -31,6 +31,8 @@
* main.
*/
+const { ipcRenderer } = require('electron');
+const { IPC } = require('../shared/ipcChannels');
const dockState = require('./dock/dockState');
const { makeSortable } = require('./dnd/sortable');
const tooltip = require('./tooltip');
@@ -348,6 +350,10 @@ function mountTab(tab) {
const slot = slots.get(tab);
if (!entry || !slot) return;
mountedTab = tab;
+ // One event for every dock panel. mountTab is the only way a panel is
+ // shown, and the early return above means a re-render of the panel you
+ // are already on does not count as opening it.
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'panel_opened', { panel: tab });
try {
entry.mount(slot);
} catch (err) {
diff --git a/src/renderer/feedbackPanel.js b/src/renderer/feedbackPanel.js
index e3f4a7d0..efbafd83 100644
--- a/src/renderer/feedbackPanel.js
+++ b/src/renderer/feedbackPanel.js
@@ -319,9 +319,9 @@ function deliver(channel, report) {
*/
function track(channel) {
try {
- ipcRenderer.send(IPC.TELEMETRY_TRACK, 'feedback_submitted', { channel });
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'feedback_submitted', { channel });
} catch (_) {
- /* telemetry never breaks a send */
+ /* analytics never breaks a send */
}
}
diff --git a/src/renderer/frameSettingsModal.js b/src/renderer/frameSettingsModal.js
index 23729e06..31ced16a 100644
--- a/src/renderer/frameSettingsModal.js
+++ b/src/renderer/frameSettingsModal.js
@@ -20,13 +20,15 @@ const { IPC } = require('../shared/ipcChannels');
const settingsOverlay = require('./settingsOverlay');
const uiZoom = require('../shared/uiZoom');
-const TELEMETRY_KEY = 'telemetryEnabled';
+const ANALYTICS_KEY = 'analyticsEnabled';
const CRASH_DUMPS_KEY = 'crashDumpsEnabled';
+const ERROR_REPORTING_KEY = 'errorReportingEnabled';
const DISMISSED_VERSION_KEY = 'dismissedUpdateVersion';
let overlay = null;
let toggleEl = null;
let crashDumpsToggleEl = null;
+let errorReportingToggleEl = null;
// Appearance › Interface size (ui-zoom-steps spec). Main owns the step; this
// select shows it and asks for another over UI_ZOOM_SET.
let zoomSelectEl = null;
@@ -43,8 +45,9 @@ let updateDismissBtn = null;
let currentUpdateInfo = null;
function init() {
- toggleEl = document.getElementById('settings-telemetry-toggle');
+ toggleEl = document.getElementById('settings-analytics-toggle');
crashDumpsToggleEl = document.getElementById('settings-crash-dumps-toggle');
+ errorReportingToggleEl = document.getElementById('settings-error-reporting-toggle');
zoomSelectEl = document.getElementById('settings-ui-zoom');
initZoomSelect();
@@ -68,18 +71,35 @@ function init() {
syncToggleFromSettings();
initAboutSection();
- // Toggle: persist + tell main process to enable/disable Aptabase
+ // Toggle: persist + tell main to enable/disable sending (and to mint or
+ // delete the install id, which setEnabled does on the spot)
toggleEl.addEventListener('change', async () => {
const enabled = toggleEl.checked;
- await ipcRenderer.invoke(IPC.SET_USER_SETTING, TELEMETRY_KEY, enabled);
- await ipcRenderer.invoke(IPC.TELEMETRY_SET_ENABLED, enabled);
+ await ipcRenderer.invoke(IPC.SET_USER_SETTING, ANALYTICS_KEY, enabled);
+ await ipcRenderer.invoke(IPC.ANALYTICS_SET_ENABLED, enabled);
+ // Only arrives when switching ON: track() is gated on the new state, so
+ // an opt-out sends nothing, which is the point of an opt-out.
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'settings_changed', { setting: 'analytics' });
+ // Error reporting rides on analytics being on, so the row follows it
+ // rather than sitting enabled over a switch that silences it.
+ syncErrorReportingAvailability(enabled);
});
+ // Error reports: opt-in, persisted setting only — analytics.captureException
+ // reads it per call, so a change takes effect immediately.
+ if (errorReportingToggleEl) {
+ errorReportingToggleEl.addEventListener('change', async () => {
+ await ipcRenderer.invoke(IPC.SET_USER_SETTING, ERROR_REPORTING_KEY, errorReportingToggleEl.checked);
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'settings_changed', { setting: 'error_reporting' });
+ });
+ }
+
// Crash dumps: persisted setting only — crashGuard reads it at startup
// (the reporter can't be stopped once started, so changes apply on next launch)
if (crashDumpsToggleEl) {
crashDumpsToggleEl.addEventListener('change', async () => {
await ipcRenderer.invoke(IPC.SET_USER_SETTING, CRASH_DUMPS_KEY, crashDumpsToggleEl.checked);
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'settings_changed', { setting: 'crash_dumps' });
});
}
@@ -299,6 +319,10 @@ function initZoomSelect() {
const step = uiZoom.clampStep(Number(zoomSelectEl.value));
try {
await ipcRenderer.invoke(IPC.UI_ZOOM_SET, step);
+ // From this control only. Zoom also changes by shortcut, menu, status
+ // bar and trackpad; counting those too would say "people zoom", not
+ // "people go to Settings to zoom", which is the question here.
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'settings_changed', { setting: 'ui_zoom' });
} catch (err) {
console.error('Frame settings: could not set the interface size', err);
}
@@ -315,13 +339,33 @@ async function syncZoomFromMain() {
}
}
+/**
+ * Error reporting is gated on analytics in main (analytics.captureException
+ * requires both), so the row is disabled rather than left looking live over a
+ * switch that silences it. The stored value is untouched — turning analytics
+ * back on restores whatever the user had chosen here.
+ */
+function syncErrorReportingAvailability(analyticsOn) {
+ if (!errorReportingToggleEl) return;
+ errorReportingToggleEl.disabled = !analyticsOn;
+ const row = errorReportingToggleEl.closest('.settings-row');
+ if (row) row.classList.toggle('settings-row-disabled', !analyticsOn);
+}
+
async function syncToggleFromSettings() {
if (!toggleEl) return;
syncZoomFromMain();
- const value = await ipcRenderer.invoke(IPC.GET_USER_SETTING, TELEMETRY_KEY);
+ const value = await ipcRenderer.invoke(IPC.GET_USER_SETTING, ANALYTICS_KEY);
// Default ON when unset (opt-out semantics)
toggleEl.checked = value !== false;
+ if (errorReportingToggleEl) {
+ const errors = await ipcRenderer.invoke(IPC.GET_USER_SETTING, ERROR_REPORTING_KEY);
+ // Default OFF when unset — this one is opt-in
+ errorReportingToggleEl.checked = errors === true;
+ syncErrorReportingAvailability(toggleEl.checked);
+ }
+
if (crashDumpsToggleEl) {
const dumps = await ipcRenderer.invoke(IPC.GET_USER_SETTING, CRASH_DUMPS_KEY);
// Default ON when unset (local-only; nothing is uploaded)
diff --git a/src/renderer/guidedTour.js b/src/renderer/guidedTour.js
index 0cc2ba65..df6ef19b 100644
--- a/src/renderer/guidedTour.js
+++ b/src/renderer/guidedTour.js
@@ -115,7 +115,7 @@ async function autoStart() {
* layers on screen at once, the automatic start waits until the user has
* dismissed them. A manual start does not wait.
*/
-const FIRST_RUN_NOTICES = ['#telemetry-notice.visible'];
+const FIRST_RUN_NOTICES = ['#analytics-notice.visible'];
function noticesGone() {
const showing = () => FIRST_RUN_NOTICES.some((selector) => document.querySelector(selector));
@@ -137,6 +137,9 @@ function noticesGone() {
async function finish(outcome) {
if (!isOpen) return;
close();
+ // Finished vs skipped is the first honest activation signal Frame has:
+ // both mean the tour was reached, only one means it landed.
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'tour_finished', { outcome });
try {
const ok = await ipcRenderer.invoke(IPC.SET_USER_SETTING, tourSteps.SETTING_KEY, {
outcome,
diff --git a/src/renderer/index.js b/src/renderer/index.js
index 84a65941..b499fa1b 100644
--- a/src/renderer/index.js
+++ b/src/renderer/index.js
@@ -48,7 +48,7 @@ const projectSettingsModal = require('./projectSettingsModal');
const doneWindow = require('./doneWindow');
const frameSettingsModal = require('./frameSettingsModal');
const feedbackPanel = require('./feedbackPanel');
-const telemetryNotice = require('./telemetryNotice');
+const analyticsNotice = require('./analyticsNotice');
const healthNotice = require('./healthNotice');
const specDrivenHint = require('./specDrivenHint');
const docsHealthHint = require('./docsHealthHint');
@@ -288,7 +288,7 @@ function init() {
feedbackPanel.init();
// The notice is about what Frame sends home — Privacy lives in Frame's
// own settings, not the project's.
- telemetryNotice.init(() => frameSettingsModal.open());
+ analyticsNotice.init(() => frameSettingsModal.open());
healthNotice.init();
sampleBanner.init();
specDrivenHint.init();
@@ -1009,6 +1009,31 @@ async function startAiSession() {
/**
* Start application when DOM is ready
*/
+// Renderer exceptions reach the same opt-in channel the main process uses.
+// Only the three fields are forwarded — an Error does not survive structured
+// cloning intact, and sending anything wider would hand the main process
+// fields nobody has sanitized. captureException does the stripping there.
+function forwardException(err) {
+ try {
+ const e = err instanceof Error ? err : new Error(String(err));
+ ipcRenderer.send(IPC.ANALYTICS_EXCEPTION, {
+ name: e.name,
+ message: e.message,
+ stack: e.stack
+ });
+ } catch (_) {
+ // Reporting a failure must never become one.
+ }
+}
+
+window.addEventListener('error', (event) => {
+ forwardException(event.error || event.message);
+});
+
+window.addEventListener('unhandledrejection', (event) => {
+ forwardException(event.reason);
+});
+
window.addEventListener('load', () => {
init();
diff --git a/src/renderer/orchestrator.js b/src/renderer/orchestrator.js
index d1ab1b71..b8f5a843 100644
--- a/src/renderer/orchestrator.js
+++ b/src/renderer/orchestrator.js
@@ -95,7 +95,7 @@ function open() {
notify.error('Open a project first');
return;
}
- ipcRenderer.send(IPC.TELEMETRY_TRACK, 'orchestrator_opened');
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'orchestrator_opened');
host.openSection('orchestrator', {}, api, { newTab: false });
}
diff --git a/src/renderer/sessionsPanel.js b/src/renderer/sessionsPanel.js
index 0044e1b3..33851086 100644
--- a/src/renderer/sessionsPanel.js
+++ b/src/renderer/sessionsPanel.js
@@ -200,6 +200,10 @@ function renderSessionsEmpty(message) {
*/
function resumeSession(sessionId) {
hide();
+ // No session id, no transcript, nothing about the work — only that the
+ // resume path was used at all, which is what says the feature earns its
+ // place.
+ ipcRenderer.send(IPC.ANALYTICS_TRACK, 'session_resumed');
require('./agentDispatch').resumeClaudeSession(sessionId);
}
diff --git a/src/renderer/styles/components/telemetry-notice.css b/src/renderer/styles/components/analytics-notice.css
similarity index 81%
rename from src/renderer/styles/components/telemetry-notice.css
rename to src/renderer/styles/components/analytics-notice.css
index a8e54998..c122304f 100644
--- a/src/renderer/styles/components/telemetry-notice.css
+++ b/src/renderer/styles/components/analytics-notice.css
@@ -1,4 +1,4 @@
-/* Telemetry Notice — one-time disclosure, a small card in the bottom-right
+/* Analytics Notice — one-time disclosure, a small card in the bottom-right
corner above the status bar.
It was a full-width strip over the top of the window on an
@@ -8,7 +8,7 @@
tour's cards sit beside their targets), and keeps its z-index above the
tour so it is never dimmed. */
-.telemetry-notice {
+.analytics-notice {
position: fixed;
right: 16px;
bottom: calc(var(--status-bar-height) + 12px);
@@ -26,12 +26,12 @@
font-family: var(--font-sans);
}
-.telemetry-notice.visible {
+.analytics-notice.visible {
display: flex;
- animation: telemetry-notice-in 0.24s ease-out;
+ animation: analytics-notice-in 0.24s ease-out;
}
-@keyframes telemetry-notice-in {
+@keyframes analytics-notice-in {
from {
opacity: 0;
transform: translateY(8px);
@@ -42,13 +42,13 @@
}
}
-.telemetry-notice-head {
+.analytics-notice-head {
display: flex;
align-items: center;
gap: var(--space-sm);
}
-.telemetry-notice-icon {
+.analytics-notice-icon {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -60,21 +60,21 @@
color: var(--accent-primary);
}
-.telemetry-notice-title {
+.analytics-notice-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
}
-.telemetry-notice-text {
+.analytics-notice-text {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--text-secondary);
}
-.telemetry-notice-actions {
+.analytics-notice-actions {
display: flex;
align-items: center;
justify-content: space-between;
@@ -82,17 +82,17 @@
margin-top: var(--space-xs);
}
-.telemetry-notice-link {
+.analytics-notice-link {
font-size: 12px;
color: var(--accent-primary);
text-decoration: none;
}
-.telemetry-notice-link:hover {
+.analytics-notice-link:hover {
text-decoration: underline;
}
-.telemetry-notice-close {
+.analytics-notice-close {
background: none;
border: none;
color: var(--text-tertiary);
@@ -104,13 +104,13 @@
transition: color 0.12s, background 0.12s;
}
-.telemetry-notice-close:hover {
+.analytics-notice-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
@media (prefers-reduced-motion: reduce) {
- .telemetry-notice.visible {
+ .analytics-notice.visible {
animation: none;
}
}
diff --git a/src/renderer/styles/components/settings-modal.css b/src/renderer/styles/components/settings-modal.css
index c378751f..da91e297 100644
--- a/src/renderer/styles/components/settings-modal.css
+++ b/src/renderer/styles/components/settings-modal.css
@@ -261,6 +261,20 @@
transition: transform 0.18s, background 0.18s;
}
+/* A switch whose channel is gated on another switch. Error reporting rides
+ on anonymous usage stats being on, so when that is off this one reads as
+ unavailable rather than sitting live over something that silences it. The
+ stored value is untouched; turning stats back on restores the choice.
+ Matches the opacity the disabled select and action button already use. */
+.settings-switch input:disabled + .settings-switch-slider {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.settings-row-disabled .settings-row-text {
+ opacity: 0.5;
+}
+
.settings-switch input:checked + .settings-switch-slider {
background: var(--accent-primary);
border-color: var(--accent-primary);
diff --git a/src/renderer/styles/main.css b/src/renderer/styles/main.css
index e9b9e16e..27d2f2ba 100644
--- a/src/renderer/styles/main.css
+++ b/src/renderer/styles/main.css
@@ -48,8 +48,8 @@
/* 11. Settings Modal */
@import 'components/settings-modal.css';
-/* 12. Telemetry Notice (one-time disclosure banner) */
-@import 'components/telemetry-notice.css';
+/* 12. Analytics Notice (one-time disclosure banner) */
+@import 'components/analytics-notice.css';
/* 12a. Health Notice (degraded/recovered state banner) */
diff --git a/src/renderer/telemetryNotice.js b/src/renderer/telemetryNotice.js
deleted file mode 100644
index 8d3ea864..00000000
--- a/src/renderer/telemetryNotice.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * Telemetry Notice Banner
- *
- * One-time card in the bottom-right corner, above the status bar, shown on
- * the first launch after telemetry was introduced. Independent of the onboarding screen so users
- * who never see that screen still get this disclosure.
- *
- * Once acknowledged, never shows again. Persisted via userSettings.
- */
-
-const { ipcRenderer } = require('electron');
-const { IPC } = require('../shared/ipcChannels');
-
-const NOTICE_SHOWN_KEY = 'telemetryNoticeShown';
-
-let bannerEl = null;
-let acknowledgeBtn = null;
-let closeBtn = null;
-let settingsLink = null;
-
-async function init(openSettings) {
- bannerEl = document.getElementById('telemetry-notice');
- acknowledgeBtn = document.getElementById('telemetry-notice-ack');
- closeBtn = document.getElementById('telemetry-notice-close');
- settingsLink = document.getElementById('telemetry-notice-settings-link');
-
- if (!bannerEl) return;
-
- // Show only if user hasn't seen it before
- const seen = await ipcRenderer.invoke(IPC.GET_USER_SETTING, NOTICE_SHOWN_KEY);
- if (seen === true) {
- bannerEl.remove();
- return;
- }
-
- bannerEl.classList.add('visible');
-
- if (acknowledgeBtn) acknowledgeBtn.addEventListener('click', dismiss);
- if (closeBtn) closeBtn.addEventListener('click', dismiss);
- if (settingsLink && typeof openSettings === 'function') {
- settingsLink.addEventListener('click', (e) => {
- e.preventDefault();
- openSettings();
- });
- }
-}
-
-function dismiss() {
- if (!bannerEl) return;
- ipcRenderer
- .invoke(IPC.SET_USER_SETTING, NOTICE_SHOWN_KEY, true)
- .catch((err) =>
- console.error('Telemetry notice: failed to persist dismiss', err)
- );
- bannerEl.classList.remove('visible');
- // Remove from DOM after fade so layout reflows
- setTimeout(() => {
- if (bannerEl && bannerEl.parentNode) bannerEl.parentNode.removeChild(bannerEl);
- bannerEl = null;
- }, 220);
-}
-
-module.exports = { init };
diff --git a/src/shared/activityEvents.js b/src/shared/activityEvents.js
index 44753bd6..07869c2d 100644
--- a/src/shared/activityEvents.js
+++ b/src/shared/activityEvents.js
@@ -3,7 +3,7 @@
*
* The registry below is the single source of truth for what Frame may record
* about its own background work. It exists for the same reason
- * `telemetryEvents.js` does, applied to a different destination: an event
+ * `analyticsEvents.js` does, applied to a different destination: an event
* that is not declared here is dropped, and a field value outside its
* declared enum is stripped. `perfMonitor.js` shows what the alternative
* looks like — eight call sites scattered over five months with no registry
@@ -459,7 +459,7 @@ function fieldPasses(spec, value) {
* Returns null for an unregistered event — the caller must drop it entirely.
* Otherwise the subset of fields that are declared and carry an acceptable
* value; unknown keys and bad values are stripped silently, exactly as
- * `telemetryEvents.validateEvent` does.
+ * `analyticsEvents.validateEvent` does.
*/
function validateEvent(name, fields) {
if (!isRegistered(name)) return null;
diff --git a/src/shared/feedbackReport.js b/src/shared/feedbackReport.js
index 9da8a59b..31495654 100644
--- a/src/shared/feedbackReport.js
+++ b/src/shared/feedbackReport.js
@@ -22,7 +22,7 @@
* Privacy is a property of this file: a body is the user's description plus,
* at most, the two lines `diagnosticsLines()` returns, and there is no code
* path here that appends a third. No project path, no project name, no
- * remote, no file contents — the same line `telemetry.js` and `PRIVACY.md`
+ * remote, no file contents — the same line `analytics.js` and `PRIVACY.md`
* hold.
*
* Pure — no Electron, no filesystem — so it runs under `node --test`.
diff --git a/src/shared/ipcChannels.js b/src/shared/ipcChannels.js
index ca56504f..6eb2667c 100644
--- a/src/shared/ipcChannels.js
+++ b/src/shared/ipcChannels.js
@@ -157,9 +157,11 @@ const IPC = {
GIT_STATUS_DATA: 'git-status-data',
GET_GIT_DIFF: 'get-git-diff',
- // Telemetry (Aptabase, opt-out via Settings)
- TELEMETRY_SET_ENABLED: 'telemetry-set-enabled',
- TELEMETRY_TRACK: 'telemetry-track',
+ // Analytics (PostHog, opt-out via Settings; exceptions are a separate opt-in)
+ ANALYTICS_SET_ENABLED: 'analytics-set-enabled',
+ ANALYTICS_TRACK: 'analytics-track',
+ ANALYTICS_EXCEPTION: 'analytics-exception',
+ ANALYTICS_NOTICE_STATE: 'analytics-notice-state',
// Health / crash recovery (main → renderer notices)
MAIN_PROCESS_ERROR: 'main-process-error',
diff --git a/test/analytics.test.js b/test/analytics.test.js
new file mode 100644
index 00000000..e1be2827
--- /dev/null
+++ b/test/analytics.test.js
@@ -0,0 +1,491 @@
+/**
+ * Analytics policy tests — fail-closed opt-out decision.
+ * Runs with Node's built-in runner: `npm test` (node --test test/).
+ *
+ * Targets the pure policy module (src/main/analyticsEvents.js); the Electron
+ * side of analytics.js is a thin wrapper over it.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+
+const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, resolveInstallId, createActiveTimer, planLegacyMigration, LEGACY_SETTING_KEYS, sanitizeException, NOTICE_VERSION, shouldShowNotice, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/analyticsEvents');
+
+// ─── effectiveEnabled — the re-opt-in regression ──────────
+
+test('never-set value on a healthy load defaults ON (opt-out semantics)', () => {
+ assert.equal(effectiveEnabled({ value: null, loadFailed: false }), true);
+});
+
+test('explicit opt-out on a healthy load stays off', () => {
+ assert.equal(effectiveEnabled({ value: false, loadFailed: false }), false);
+});
+
+test('explicit opt-in on a healthy load stays on', () => {
+ assert.equal(effectiveEnabled({ value: true, loadFailed: false }), true);
+});
+
+test('failed settings load fails CLOSED regardless of the cached value', () => {
+ // The re-opt-in bug: an unrecoverable user-settings.json used to reset the
+ // cache to {} so `null !== false` re-enabled analytics for opted-out users.
+ assert.equal(effectiveEnabled({ value: null, loadFailed: true }), false);
+ assert.equal(effectiveEnabled({ value: true, loadFailed: true }), false);
+ assert.equal(effectiveEnabled({ value: false, loadFailed: true }), false);
+});
+
+// ─── resolveInstallId — one stable id, and no id at all ───
+
+const UUID_A = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
+const mintB = () => '9f8e7d6c-5b4a-4938-8271-615243342516';
+
+test('no stored id mints one and asks for it to be written', () => {
+ const r = resolveInstallId({ stored: null, enabled: true }, mintB);
+ assert.equal(r.id, mintB());
+ assert.equal(r.write, 'set');
+});
+
+test('a stored id is reused and needs no write', () => {
+ const r = resolveInstallId({ stored: UUID_A, enabled: true }, mintB);
+ assert.equal(r.id, UUID_A);
+ assert.equal(r.write, null);
+});
+
+test('a malformed stored id is replaced rather than sent', () => {
+ for (const bad of ['', 'not-a-uuid', 42, {}, 'hostname-of-this-mac']) {
+ const r = resolveInstallId({ stored: bad, enabled: true }, mintB);
+ assert.equal(r.id, mintB(), `${JSON.stringify(bad)} should be replaced`);
+ assert.equal(r.write, 'set');
+ }
+});
+
+test('analytics off yields no id and asks for the stored one to be deleted', () => {
+ const r = resolveInstallId({ stored: UUID_A, enabled: false }, mintB);
+ assert.equal(r.id, null);
+ assert.equal(r.write, 'delete');
+});
+
+test('analytics off with nothing stored writes nothing', () => {
+ const r = resolveInstallId({ stored: null, enabled: false }, mintB);
+ assert.equal(r.id, null);
+ assert.equal(r.write, null);
+});
+
+test('a minted id is a random UUID, not derived from the machine', () => {
+ const a = resolveInstallId({ stored: null, enabled: true });
+ const b = resolveInstallId({ stored: null, enabled: true });
+ assert.match(a.id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+ assert.notEqual(a.id, b.id);
+});
+
+// ─── opt-out leaves no identifier behind ──────────────────
+//
+// The composition is the point: whatever decides analytics is off — an
+// explicit opt-out or a settings file that could not be read — must also
+// leave no install id on disk. Testing the two functions separately would
+// miss a wiring that consulted the wrong one.
+
+const idFor = (state) =>
+ resolveInstallId({ stored: state.stored, enabled: effectiveEnabled(state) }, mintB);
+
+test('an explicit opt-out yields no id and deletes the stored one', () => {
+ const r = idFor({ value: false, loadFailed: false, stored: UUID_A });
+ assert.equal(r.id, null);
+ assert.equal(r.write, 'delete');
+});
+
+test('an unreadable settings file yields no id, whatever was cached', () => {
+ for (const value of [null, true, false]) {
+ const r = idFor({ value, loadFailed: true, stored: UUID_A });
+ assert.equal(r.id, null, `loadFailed with cached ${value} must send nothing`);
+ assert.equal(r.write, 'delete');
+ }
+});
+
+test('turning analytics back on mints a new id, not the old one', () => {
+ const off = idFor({ value: false, loadFailed: false, stored: UUID_A });
+ assert.equal(off.write, 'delete');
+ // The delete has landed, so the re-enable sees nothing stored.
+ const on = idFor({ value: true, loadFailed: false, stored: null });
+ assert.equal(on.write, 'set');
+ assert.notEqual(on.id, UUID_A);
+});
+
+test('a healthy default-on install keeps its id across launches', () => {
+ const first = idFor({ value: null, loadFailed: false, stored: null });
+ assert.equal(first.write, 'set');
+ const second = idFor({ value: null, loadFailed: false, stored: first.id });
+ assert.equal(second.id, first.id);
+ assert.equal(second.write, null);
+});
+
+// ─── sanitizeException — the opt-in channel's safety ──────
+
+test('an absolute POSIX path in a message is reduced to its basename', () => {
+ const r = sanitizeException(
+ new Error("ENOENT: no such file or directory, open '/Users/someone/their-startup/src/billing.js'")
+ );
+ assert.equal(r.message, "ENOENT: no such file or directory, open 'billing.js'");
+ assert.ok(!r.message.includes('someone'));
+ assert.ok(!r.message.includes('their-startup'));
+});
+
+test('a Windows path is reduced the same way', () => {
+ const r = sanitizeException({ message: String.raw`open 'C:\Users\someone\their-startup\billing.js'` });
+ assert.equal(r.message, "open 'billing.js'");
+});
+
+test('stack frames keep the file and line, lose the tree above it', () => {
+ const stack = [
+ 'Error: boom',
+ ' at thing (/Users/someone/their-startup/src/thing.js:10:5)',
+ ' at run (/Applications/Frame.app/Contents/Resources/app/src/main/index.js:42:1)'
+ ].join('\n');
+ const r = sanitizeException({ message: 'boom', stack });
+ assert.ok(r.stack.includes('at thing (thing.js:10:5)'));
+ assert.ok(r.stack.includes('at run (index.js:42:1)'));
+ assert.ok(!r.stack.includes('their-startup'));
+});
+
+test('a URL survives — it is not somebody\'s filesystem', () => {
+ const r = sanitizeException({ message: 'POST https://eu.i.posthog.com/batch failed with 503' });
+ assert.equal(r.message, 'POST https://eu.i.posthog.com/batch failed with 503');
+});
+
+test('secret shapes are redacted before anything else happens', () => {
+ const r = sanitizeException({ message: 'auth failed token=abcdef123456 using sk-ant-abcdefghijklmno' });
+ assert.ok(!r.message.includes('abcdef123456'));
+ assert.ok(!r.message.includes('sk-ant-abcdefghijklmno'));
+ assert.ok(r.message.includes('[REDACTED]'));
+});
+
+test('sanitizeException never throws and never returns undefined fields', () => {
+ for (const input of [undefined, null, {}, { message: 42 }, new Error()]) {
+ const r = sanitizeException(input);
+ assert.equal(typeof r.name, 'string');
+ assert.equal(typeof r.message, 'string');
+ assert.equal(typeof r.stack, 'string');
+ }
+});
+
+// ─── shouldShowNotice — a disclosure that can change ──────
+
+test('a fresh install sees the notice', () => {
+ assert.equal(shouldShowNotice({ storedVersion: undefined, legacyShown: undefined }), true);
+});
+
+test('a client that only stored the old boolean sees the new notice once', () => {
+ // It read *a* disclosure — the one without an install id — so it counts as
+ // version 1 and the current version is still ahead of it.
+ assert.equal(shouldShowNotice({ storedVersion: undefined, legacyShown: true }), true);
+ assert.equal(shouldShowNotice({ storedVersion: undefined, legacyShown: true, currentVersion: 1 }), false);
+});
+
+test('a client that has seen the current version is left alone', () => {
+ assert.equal(shouldShowNotice({ storedVersion: NOTICE_VERSION }), false);
+});
+
+test('a stored version ahead of the current one does not re-show', () => {
+ assert.equal(shouldShowNotice({ storedVersion: NOTICE_VERSION + 1 }), false);
+});
+
+test('a garbage stored version is treated as never seen', () => {
+ for (const junk of ['2', null, {}, NaN, 1.5]) {
+ assert.equal(shouldShowNotice({ storedVersion: junk }), true, `${String(junk)} should re-show`);
+ }
+});
+
+// ─── planLegacyMigration — the rename must not lose a choice ──
+
+test('an explicit opt-out survives the telemetry→analytics rename', () => {
+ // The whole point: read as "never set", false would default back ON.
+ const writes = planLegacyMigration({ telemetryEnabled: false });
+ assert.deepEqual(writes, [{ key: 'analyticsEnabled', value: false }]);
+});
+
+test('the install id carries over, so one user does not become two', () => {
+ const writes = planLegacyMigration({ telemetryInstallId: UUID_A });
+ assert.deepEqual(writes, [{ key: 'analyticsInstallId', value: UUID_A }]);
+});
+
+test('a seen notice version carries over, so nobody is interrupted twice', () => {
+ const writes = planLegacyMigration({ telemetryNoticeVersion: 2 });
+ assert.deepEqual(writes, [{ key: 'analyticsNoticeVersion', value: 2 }]);
+});
+
+test('a value already set under the new name is never overwritten', () => {
+ const writes = planLegacyMigration({ analyticsEnabled: true, telemetryEnabled: false });
+ assert.deepEqual(writes, []);
+});
+
+test('a fresh install has nothing to migrate', () => {
+ assert.deepEqual(planLegacyMigration({}), []);
+ assert.deepEqual(planLegacyMigration(undefined), []);
+});
+
+test('every legacy key maps to a distinct current key', () => {
+ const current = Object.keys(LEGACY_SETTING_KEYS);
+ const legacy = Object.values(LEGACY_SETTING_KEYS);
+ assert.equal(new Set(current).size, current.length);
+ assert.equal(new Set(legacy).size, legacy.length);
+ for (const k of legacy) assert.ok(k.startsWith('telemetry'), `${k} should be an old-name key`);
+ for (const k of current) assert.ok(k.startsWith('analytics'), `${k} should be a new-name key`);
+});
+
+// ─── The registry is enum-only ────────────────────────────
+
+test('every registry prop is a fixed enum or a declared number — never free-form', () => {
+ for (const [event, schema] of Object.entries(EVENTS)) {
+ for (const [prop, spec] of Object.entries(schema)) {
+ if (Array.isArray(spec)) {
+ assert.ok(spec.length > 0, `${event}.${prop} enum must not be empty`);
+ for (const v of spec) {
+ assert.equal(typeof v, 'string', `${event}.${prop} values must be strings`);
+ }
+ continue;
+ }
+ // The only other shape allowed. A number cannot carry a path, a
+ // project name or a prompt, which is what the enum rule protected.
+ assert.equal(typeof spec, 'object', `${event}.${prop} must be an enum array or a numeric spec`);
+ assert.equal(spec.type, 'number', `${event}.${prop} numeric spec needs type:'number'`);
+ assert.equal(typeof spec.max, 'number', `${event}.${prop} numeric spec needs a max`);
+ }
+ }
+});
+
+test('a numeric prop takes a number in range and refuses everything else', () => {
+ assert.deepEqual(validateEvent('app_session_ended', { active_seconds: 1830.7 }), { active_seconds: 1831 });
+ assert.deepEqual(validateEvent('app_session_ended', { active_seconds: 0 }), { active_seconds: 0 });
+ for (const junk of ['/Users/someone/project/a.js', '600', NaN, Infinity, -1, 86401, null, {}, true]) {
+ assert.deepEqual(
+ validateEvent('app_session_ended', { active_seconds: junk }),
+ {},
+ `${String(junk)} must be dropped`
+ );
+ }
+});
+
+test('createActiveTimer counts ticks, not elapsed clock time', () => {
+ // The distinction is the point: the caller's interval is paused while the
+ // app is hidden, so only fired ticks may become active seconds.
+ const t = createActiveTimer({ tickMs: 30000 });
+ assert.equal(t.seconds(), 0);
+ t.tick(); t.tick();
+ assert.equal(t.seconds(), 60);
+});
+
+test('an active timer that never ticks reports zero, not null', () => {
+ const t = createActiveTimer();
+ assert.equal(t.seconds(), 0);
+ assert.deepEqual(validateEvent('app_session_ended', { active_seconds: t.seconds() }), { active_seconds: 0 });
+});
+
+// ─── validateEvent ────────────────────────────────────────
+
+test('unregistered event returns null', () => {
+ assert.equal(validateEvent('made_up_event', {}), null);
+});
+
+test('registered event with no props passes with empty props', () => {
+ assert.deepEqual(validateEvent('spec_created', undefined), {});
+});
+
+test('unknown props are stripped', () => {
+ assert.deepEqual(
+ validateEvent('spec_phase_advanced', { phase: 'planned', projectPath: '/Users/x/secret' }),
+ { phase: 'planned' }
+ );
+});
+
+test('out-of-enum values are stripped', () => {
+ assert.deepEqual(validateEvent('spec_phase_advanced', { phase: 'not-a-phase' }), {});
+ assert.deepEqual(validateEvent('error_occurred', { category: 'stack: at foo()' }), {});
+});
+
+// ─── spec_created origin (new-spec-agent-handoff T07) ─────
+//
+// The property is the difference between "specs are being created" and "the
+// New Spec launcher is being used". It degrades in one direction only: a
+// failed attribution must land on today's bare event, never on a wrong value.
+
+test('every spec_created origin the code can send is accepted', () => {
+ for (const origin of ['button', 'agent', 'conductor']) {
+ assert.deepEqual(validateEvent('spec_created', { origin }), { origin });
+ }
+});
+
+test('an unknown origin is dropped, leaving the bare event', () => {
+ assert.deepEqual(validateEvent('spec_created', { origin: 'modal' }), {});
+ assert.deepEqual(validateEvent('spec_created', { origin: '/Users/x/specs/secret' }), {});
+});
+
+test('an absent origin is still a valid spec_created', () => {
+ assert.deepEqual(validateEvent('spec_created', {}), {});
+ assert.deepEqual(validateEvent('spec_created', undefined), {});
+ assert.deepEqual(validateEvent('spec_created', { origin: undefined }), {});
+});
+
+test('the origin enum matches PRIVACY.md', () => {
+ const fs = require('fs');
+ const path = require('path');
+ const row = fs.readFileSync(path.join(__dirname, '..', 'PRIVACY.md'), 'utf8')
+ .split('\n')
+ .find((line) => line.startsWith('| `spec_created`'));
+ assert.ok(row, 'PRIVACY.md must carry a spec_created row');
+ for (const origin of EVENTS.spec_created.origin) {
+ assert.ok(row.includes(`\`${origin}\``), `PRIVACY.md must document origin ${origin}`);
+ }
+});
+
+test('tool props are normalized before the enum check', () => {
+ assert.deepEqual(validateEvent('agent_run_started', { tool: 'claude-code' }), { tool: 'claude' });
+ assert.deepEqual(validateEvent('ai_tool_selected', { tool: 'my-secret-tool' }), { tool: 'custom' });
+});
+
+// ─── normalizeTool ────────────────────────────────────────
+
+test('normalizeTool collapses everything outside the built-ins to custom', () => {
+ assert.equal(normalizeTool('claude'), 'claude');
+ assert.equal(normalizeTool('codex'), 'codex');
+ assert.equal(normalizeTool('gemini'), 'gemini');
+ assert.equal(normalizeTool('claude-code'), 'claude');
+ assert.equal(normalizeTool('aider'), 'custom');
+ assert.equal(normalizeTool(''), 'custom');
+ assert.equal(normalizeTool(undefined), 'custom');
+ assert.equal(normalizeTool(null), 'custom');
+});
+
+// ─── Rate limiting — the quota is finite ──────────────────
+
+test('normal use is never rate limited', () => {
+ const limiter = createRateLimiter();
+ // A busy minute of real user activity: every event goes out.
+ for (let i = 0; i < DEFAULT_RATE_LIMIT.perWindow; i++) {
+ assert.equal(limiter.check(1000 + i * 100).allowed, true, `event ${i} sent`);
+ }
+ assert.equal(limiter.stats().suppressed, 0);
+});
+
+test('a burst past the window cap is dropped, and says so once', () => {
+ // Two Frames on one project: the older build walks spec phases back, this
+ // one reconciles them forward, and the loop bills the analytics quota.
+ const limiter = createRateLimiter();
+ for (let i = 0; i < DEFAULT_RATE_LIMIT.perWindow; i++) limiter.check(1000);
+
+ const first = limiter.check(1000);
+ assert.equal(first.allowed, false, 'over the cap');
+ assert.match(first.notice, /rate limit reached/, 'and it is reported');
+
+ const next = limiter.check(1001);
+ assert.equal(next.allowed, false);
+ assert.equal(next.notice, null, 'but not reported per dropped event');
+
+ assert.equal(limiter.stats().suppressed, 2);
+});
+
+test('the window rolls: a quiet minute restores the budget', () => {
+ const limiter = createRateLimiter();
+ for (let i = 0; i < DEFAULT_RATE_LIMIT.perWindow; i++) limiter.check(1000);
+ assert.equal(limiter.check(1000).allowed, false);
+
+ const later = 1000 + DEFAULT_RATE_LIMIT.windowMs;
+ assert.equal(limiter.check(later).allowed, true, 'the old window has expired');
+});
+
+test('the session ceiling holds even when every window is under the cap', () => {
+ // A slow loop — one event every few seconds, forever — stays under the
+ // per-minute cap, so the session ceiling is what bounds it.
+ const limiter = createRateLimiter({ perWindow: 1000, perSession: 5 });
+ for (let i = 0; i < 5; i++) {
+ assert.equal(limiter.check(i * 10_000).allowed, true);
+ }
+ const over = limiter.check(6 * 10_000);
+ assert.equal(over.allowed, false);
+ assert.match(over.notice, /this run has sent 5 events/);
+});
+
+// ─── diffSpecLifecycle — spec_created / spec_phase_advanced ─
+
+const { diffSpecLifecycle, renameSpecLifecycle } = require('../src/main/analyticsEvents');
+
+const HOUR = 60 * 60 * 1000;
+const T0 = Date.parse('2026-09-14T10:00:00Z');
+const iso = (ms) => new Date(ms).toISOString();
+const spec = (slug, phase, extra) => Object.assign(
+ { slug, phase, authored: true, created_at: iso(T0 - 24 * HOUR), last_phase_at: iso(T0 - 24 * HOUR) },
+ extra
+);
+
+function seed(specs) {
+ return diffSpecLifecycle(null, specs, T0).state;
+}
+
+test('the first look seeds without counting anything already on disk', () => {
+ const r = diffSpecLifecycle(null, [spec('a', 'done'), spec('b', 'planned')], T0);
+ assert.deepEqual(r.created, []);
+ assert.deepEqual(r.advanced, []);
+});
+
+test('a spec authored now counts once, and not again after leaving and returning', () => {
+ let state = seed([]);
+ const now = T0 + 60_000;
+ const fresh = spec('a', 'specified', { created_at: iso(now) });
+ let r = diffSpecLifecycle(state, [fresh], now);
+ assert.deepEqual(r.created, ['a']);
+ state = r.state;
+ // branch switched away (spec gone), then back
+ state = diffSpecLifecycle(state, [], now + 1000).state;
+ r = diffSpecLifecycle(state, [fresh], now + 2000);
+ assert.deepEqual(r.created, [], 'a returning spec is not a new one');
+});
+
+test('a spec.md added to a slug already seen counts regardless of its created_at', () => {
+ let state = seed([spec('a', 'draft', { authored: false })]);
+ const r = diffSpecLifecycle(state, [spec('a', 'draft')], T0 + 60_000);
+ assert.deepEqual(r.created, ['a']);
+});
+
+test('a checked-out or pulled spec stamped before the last look does not count', () => {
+ const state = seed([]);
+ const r = diffSpecLifecycle(state, [spec('old', 'done')], T0 + 60_000);
+ assert.deepEqual(r.created, []);
+ assert.deepEqual(r.advanced, []);
+});
+
+test('an unreadable or date-only created_at counts — Frame cannot tell it is old', () => {
+ const state = seed([]);
+ const r = diffSpecLifecycle(state, [
+ spec('x', 'draft', { created_at: null }),
+ spec('y', 'draft', { created_at: '2026-01-01' })
+ ], T0 + 60_000);
+ assert.deepEqual(r.created, ['x', 'y']);
+});
+
+test('a phase counts only past the furthest one seen — regressions and ping-pong do not', () => {
+ let state = seed([spec('a', 'planned')]);
+ const now = T0 + 60_000;
+ let r = diffSpecLifecycle(state, [spec('a', 'tasks_generated', { last_phase_at: iso(now) })], now);
+ assert.deepEqual(r.advanced, [{ slug: 'a', phase: 'tasks_generated' }]);
+ r = diffSpecLifecycle(r.state, [spec('a', 'planned', { last_phase_at: iso(now) })], now + 1000);
+ assert.deepEqual(r.advanced, []);
+ r = diffSpecLifecycle(r.state, [spec('a', 'tasks_generated', { last_phase_at: iso(now + 2000) })], now + 2000);
+ assert.deepEqual(r.advanced, [], 'returning to a phase already counted');
+});
+
+test('a phase jump stamped before the last look (checkout) does not count', () => {
+ const state = seed([spec('a', 'planned')]);
+ const r = diffSpecLifecycle(state, [spec('a', 'done')], T0 + 60_000);
+ assert.deepEqual(r.advanced, []);
+});
+
+test('a spec that never had a readable phase is not advancing when it gets one', () => {
+ const state = seed([spec('a', null)]);
+ const r = diffSpecLifecycle(state, [spec('a', 'draft', { last_phase_at: iso(T0 + 1000) })], T0 + 1000);
+ assert.deepEqual(r.advanced, []);
+});
+
+test('a rename carries history — the new slug is not a created spec', () => {
+ const state = renameSpecLifecycle(seed([spec('old-name', 'planned', { created_at: iso(T0) })]), 'old-name', 'new-name');
+ const r = diffSpecLifecycle(state, [spec('new-name', 'planned', { created_at: iso(T0) })], T0 + 1000);
+ assert.deepEqual(r.created, []);
+ assert.deepEqual(r.advanced, []);
+});
diff --git a/test/codexHookInstall.test.js b/test/codexHookInstall.test.js
index 8171318d..f409f545 100644
--- a/test/codexHookInstall.test.js
+++ b/test/codexHookInstall.test.js
@@ -17,12 +17,13 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
-// frameProject reaches Electron and telemetry transitively; CI runs with no
+// frameProject reaches Electron and analytics transitively; CI runs with no
// node_modules on purpose, so both are stubbed the way specTasksSync does it.
const Module = require('node:module');
const ACTIVE = { id: 'codex' };
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: {}, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/codexHookTrust.test.js b/test/codexHookTrust.test.js
index c43511c6..c2a32eba 100644
--- a/test/codexHookTrust.test.js
+++ b/test/codexHookTrust.test.js
@@ -25,6 +25,7 @@ const Module = require('node:module');
const ACTIVE = { id: 'codex' };
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: {}, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/codexTemplates.test.js b/test/codexTemplates.test.js
index a394b303..a0eed5fa 100644
--- a/test/codexTemplates.test.js
+++ b/test/codexTemplates.test.js
@@ -25,6 +25,7 @@ const { execFileSync } = require('child_process');
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: {}, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/frameProjectInit.test.js b/test/frameProjectInit.test.js
index 6cdb0f63..3740ed35 100644
--- a/test/frameProjectInit.test.js
+++ b/test/frameProjectInit.test.js
@@ -8,7 +8,7 @@
* is byte-identical afterwards, because Frame no longer reads, consumes or
* replaces anything at the project root.
*
- * Electron and the telemetry package are stubbed (the specTasksSync pattern):
+ * Electron and the analytics package are stubbed (the specTasksSync pattern):
* CI runs this suite with no node_modules.
*/
@@ -22,6 +22,7 @@ const crypto = require('crypto');
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: {
app: { getPath: () => os.tmpdir(), getVersion: () => '0.0.0-test' },
ipcMain: { handle() {}, on() {} },
diff --git a/test/frameProjectOpen.test.js b/test/frameProjectOpen.test.js
index 01efb7c9..2b1be307 100644
--- a/test/frameProjectOpen.test.js
+++ b/test/frameProjectOpen.test.js
@@ -9,7 +9,7 @@
* with the artifacts an already-migrated one has, and five opens produce the
* tree the first one did.
*
- * Electron and the telemetry package are stubbed (the frameProjectInit
+ * Electron and the analytics package are stubbed (the frameProjectInit
* pattern): CI runs this suite with no node_modules.
*/
@@ -24,6 +24,7 @@ const { execFileSync } = require('child_process');
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: {
app: { getPath: () => os.tmpdir(), getVersion: () => '0.0.0-test' },
ipcMain: { handle() {}, on() {} },
diff --git a/test/gitSharing.test.js b/test/gitSharing.test.js
index fa3dce8e..79aa6246 100644
--- a/test/gitSharing.test.js
+++ b/test/gitSharing.test.js
@@ -16,6 +16,7 @@ const { execFileSync } = require('child_process');
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: { getPath: () => os.tmpdir() }, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/layoutMigration.test.js b/test/layoutMigration.test.js
index 898550fc..1e377c1d 100644
--- a/test/layoutMigration.test.js
+++ b/test/layoutMigration.test.js
@@ -20,6 +20,7 @@ const { execFileSync } = require('child_process');
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: { getPath: () => os.tmpdir() }, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/spec-command-hint.test.js b/test/spec-command-hint.test.js
index 21c94f26..e7d7c6c7 100644
--- a/test/spec-command-hint.test.js
+++ b/test/spec-command-hint.test.js
@@ -25,15 +25,16 @@ const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
-// specManager reaches two packages transitively: telemetry.js requires
+// specManager reaches two packages transitively: analytics.js requires
// @aptabase/electron/main and userSettings.js requires electron. CI runs this
// suite with no node_modules on purpose (see .github/workflows/ci.yml), so
// both are stubbed before it loads — the same idiom specTasksSync.test.js
-// uses. Neither is exercised: getCommandPrompt emits no telemetry, and
+// uses. Neither is exercised: getCommandPrompt emits no analytics, and
// app.getPath is only reached from userSettings.init(), which nothing calls.
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: {}, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/specNewStaging.test.js b/test/specNewStaging.test.js
index 62f3ad65..9322c359 100644
--- a/test/specNewStaging.test.js
+++ b/test/specNewStaging.test.js
@@ -30,15 +30,16 @@ const os = require('os');
const path = require('path');
// CI runs this suite with no node_modules (see .github/workflows/ci.yml), and
-// specManager reaches two packages transitively: telemetry.js requires
+// specManager reaches two packages transitively: analytics.js requires
// @aptabase/electron/main and userSettings.js requires electron. Both are
// stubbed before it loads — the same idiom specTasksSync.test.js and
// spec-command-hint.test.js use. Neither is exercised here: getCommandPrompt
-// emits no telemetry, and app.getPath is only reached from
+// emits no analytics, and app.getPath is only reached from
// userSettings.init(), which nothing here calls.
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: {}, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/specStatusRepair.test.js b/test/specStatusRepair.test.js
index 60a1339b..b99cf98b 100644
--- a/test/specStatusRepair.test.js
+++ b/test/specStatusRepair.test.js
@@ -18,15 +18,16 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
-// specManager reaches two packages transitively: telemetry.js requires
+// specManager reaches two packages transitively: analytics.js requires
// @aptabase/electron/main, and userSettings.js requires electron. CI runs this
// suite with no node_modules on purpose (see .github/workflows/ci.yml), so both
// are stubbed before specManager loads — the same shim specTasksSync.test.js
// uses. Neither is exercised here: listing and repairing a spec emits no
-// telemetry, and app.getPath is only reached from userSettings.init().
+// analytics, and app.getPath is only reached from userSettings.init().
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: {}, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/specTasksSync.test.js b/test/specTasksSync.test.js
index 66641326..e9984cf6 100644
--- a/test/specTasksSync.test.js
+++ b/test/specTasksSync.test.js
@@ -18,15 +18,16 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
-// specManager reaches two packages transitively: telemetry.js requires
+// specManager reaches two packages transitively: analytics.js requires
// @aptabase/electron/main, and userSettings.js requires electron. CI runs this
// suite with no node_modules on purpose (see .github/workflows/ci.yml), so both
// are stubbed before specManager loads. Neither is exercised here —
-// syncTasksFromMarkdown emits no telemetry, and app.getPath is only reached
+// syncTasksFromMarkdown emits no analytics, and app.getPath is only reached
// from userSettings.init(), which nothing here calls.
const Module = require('node:module');
const EXTERNAL_STUBS = {
'@aptabase/electron/main': { initialize() {}, trackEvent() {} },
+ 'posthog-node': { PostHog: class { capture() {} captureException() {} identify() {} shutdown() { return Promise.resolve(); } } },
electron: { app: {}, ipcMain: { handle() {}, on() {} } }
};
const loadOriginal = Module._load;
diff --git a/test/telemetry.test.js b/test/telemetry.test.js
deleted file mode 100644
index 44c6c255..00000000
--- a/test/telemetry.test.js
+++ /dev/null
@@ -1,259 +0,0 @@
-/**
- * Telemetry policy tests — fail-closed opt-out decision.
- * Runs with Node's built-in runner: `npm test` (node --test test/).
- *
- * Targets the pure policy module (src/main/telemetryEvents.js); the Electron
- * side of telemetry.js is a thin wrapper over it.
- */
-
-const { test } = require('node:test');
-const assert = require('node:assert/strict');
-
-const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/telemetryEvents');
-
-// ─── effectiveEnabled — the re-opt-in regression ──────────
-
-test('never-set value on a healthy load defaults ON (opt-out semantics)', () => {
- assert.equal(effectiveEnabled({ value: null, loadFailed: false }), true);
-});
-
-test('explicit opt-out on a healthy load stays off', () => {
- assert.equal(effectiveEnabled({ value: false, loadFailed: false }), false);
-});
-
-test('explicit opt-in on a healthy load stays on', () => {
- assert.equal(effectiveEnabled({ value: true, loadFailed: false }), true);
-});
-
-test('failed settings load fails CLOSED regardless of the cached value', () => {
- // The re-opt-in bug: an unrecoverable user-settings.json used to reset the
- // cache to {} so `null !== false` re-enabled telemetry for opted-out users.
- assert.equal(effectiveEnabled({ value: null, loadFailed: true }), false);
- assert.equal(effectiveEnabled({ value: true, loadFailed: true }), false);
- assert.equal(effectiveEnabled({ value: false, loadFailed: true }), false);
-});
-
-// ─── The registry is enum-only ────────────────────────────
-
-test('registry props are arrays of fixed strings — no free-form values possible', () => {
- for (const [event, schema] of Object.entries(EVENTS)) {
- for (const [prop, allowed] of Object.entries(schema)) {
- assert.ok(Array.isArray(allowed), `${event}.${prop} must be an enum array`);
- assert.ok(allowed.length > 0, `${event}.${prop} enum must not be empty`);
- for (const v of allowed) {
- assert.equal(typeof v, 'string', `${event}.${prop} values must be strings`);
- }
- }
- }
-});
-
-// ─── validateEvent ────────────────────────────────────────
-
-test('unregistered event returns null', () => {
- assert.equal(validateEvent('made_up_event', {}), null);
-});
-
-test('registered event with no props passes with empty props', () => {
- assert.deepEqual(validateEvent('spec_created', undefined), {});
-});
-
-test('unknown props are stripped', () => {
- assert.deepEqual(
- validateEvent('spec_phase_advanced', { phase: 'planned', projectPath: '/Users/x/secret' }),
- { phase: 'planned' }
- );
-});
-
-test('out-of-enum values are stripped', () => {
- assert.deepEqual(validateEvent('spec_phase_advanced', { phase: 'not-a-phase' }), {});
- assert.deepEqual(validateEvent('error_occurred', { category: 'stack: at foo()' }), {});
-});
-
-// ─── spec_created origin (new-spec-agent-handoff T07) ─────
-//
-// The property is the difference between "specs are being created" and "the
-// New Spec launcher is being used". It degrades in one direction only: a
-// failed attribution must land on today's bare event, never on a wrong value.
-
-test('every spec_created origin the code can send is accepted', () => {
- for (const origin of ['button', 'agent', 'conductor']) {
- assert.deepEqual(validateEvent('spec_created', { origin }), { origin });
- }
-});
-
-test('an unknown origin is dropped, leaving the bare event', () => {
- assert.deepEqual(validateEvent('spec_created', { origin: 'modal' }), {});
- assert.deepEqual(validateEvent('spec_created', { origin: '/Users/x/specs/secret' }), {});
-});
-
-test('an absent origin is still a valid spec_created', () => {
- assert.deepEqual(validateEvent('spec_created', {}), {});
- assert.deepEqual(validateEvent('spec_created', undefined), {});
- assert.deepEqual(validateEvent('spec_created', { origin: undefined }), {});
-});
-
-test('the origin enum matches PRIVACY.md', () => {
- const fs = require('fs');
- const path = require('path');
- const row = fs.readFileSync(path.join(__dirname, '..', 'PRIVACY.md'), 'utf8')
- .split('\n')
- .find((line) => line.startsWith('| `spec_created`'));
- assert.ok(row, 'PRIVACY.md must carry a spec_created row');
- for (const origin of EVENTS.spec_created.origin) {
- assert.ok(row.includes(`\`${origin}\``), `PRIVACY.md must document origin ${origin}`);
- }
-});
-
-test('tool props are normalized before the enum check', () => {
- assert.deepEqual(validateEvent('agent_run_started', { tool: 'claude-code' }), { tool: 'claude' });
- assert.deepEqual(validateEvent('ai_tool_selected', { tool: 'my-secret-tool' }), { tool: 'custom' });
-});
-
-// ─── normalizeTool ────────────────────────────────────────
-
-test('normalizeTool collapses everything outside the built-ins to custom', () => {
- assert.equal(normalizeTool('claude'), 'claude');
- assert.equal(normalizeTool('codex'), 'codex');
- assert.equal(normalizeTool('gemini'), 'gemini');
- assert.equal(normalizeTool('claude-code'), 'claude');
- assert.equal(normalizeTool('aider'), 'custom');
- assert.equal(normalizeTool(''), 'custom');
- assert.equal(normalizeTool(undefined), 'custom');
- assert.equal(normalizeTool(null), 'custom');
-});
-
-// ─── Rate limiting — the quota is finite ──────────────────
-
-test('normal use is never rate limited', () => {
- const limiter = createRateLimiter();
- // A busy minute of real user activity: every event goes out.
- for (let i = 0; i < DEFAULT_RATE_LIMIT.perWindow; i++) {
- assert.equal(limiter.check(1000 + i * 100).allowed, true, `event ${i} sent`);
- }
- assert.equal(limiter.stats().suppressed, 0);
-});
-
-test('a burst past the window cap is dropped, and says so once', () => {
- // Two Frames on one project: the older build walks spec phases back, this
- // one reconciles them forward, and the loop bills the analytics quota.
- const limiter = createRateLimiter();
- for (let i = 0; i < DEFAULT_RATE_LIMIT.perWindow; i++) limiter.check(1000);
-
- const first = limiter.check(1000);
- assert.equal(first.allowed, false, 'over the cap');
- assert.match(first.notice, /rate limit reached/, 'and it is reported');
-
- const next = limiter.check(1001);
- assert.equal(next.allowed, false);
- assert.equal(next.notice, null, 'but not reported per dropped event');
-
- assert.equal(limiter.stats().suppressed, 2);
-});
-
-test('the window rolls: a quiet minute restores the budget', () => {
- const limiter = createRateLimiter();
- for (let i = 0; i < DEFAULT_RATE_LIMIT.perWindow; i++) limiter.check(1000);
- assert.equal(limiter.check(1000).allowed, false);
-
- const later = 1000 + DEFAULT_RATE_LIMIT.windowMs;
- assert.equal(limiter.check(later).allowed, true, 'the old window has expired');
-});
-
-test('the session ceiling holds even when every window is under the cap', () => {
- // A slow loop — one event every few seconds, forever — stays under the
- // per-minute cap, so the session ceiling is what bounds it.
- const limiter = createRateLimiter({ perWindow: 1000, perSession: 5 });
- for (let i = 0; i < 5; i++) {
- assert.equal(limiter.check(i * 10_000).allowed, true);
- }
- const over = limiter.check(6 * 10_000);
- assert.equal(over.allowed, false);
- assert.match(over.notice, /this run has sent 5 events/);
-});
-
-// ─── diffSpecLifecycle — spec_created / spec_phase_advanced ─
-
-const { diffSpecLifecycle, renameSpecLifecycle } = require('../src/main/telemetryEvents');
-
-const HOUR = 60 * 60 * 1000;
-const T0 = Date.parse('2026-09-14T10:00:00Z');
-const iso = (ms) => new Date(ms).toISOString();
-const spec = (slug, phase, extra) => Object.assign(
- { slug, phase, authored: true, created_at: iso(T0 - 24 * HOUR), last_phase_at: iso(T0 - 24 * HOUR) },
- extra
-);
-
-function seed(specs) {
- return diffSpecLifecycle(null, specs, T0).state;
-}
-
-test('the first look seeds without counting anything already on disk', () => {
- const r = diffSpecLifecycle(null, [spec('a', 'done'), spec('b', 'planned')], T0);
- assert.deepEqual(r.created, []);
- assert.deepEqual(r.advanced, []);
-});
-
-test('a spec authored now counts once, and not again after leaving and returning', () => {
- let state = seed([]);
- const now = T0 + 60_000;
- const fresh = spec('a', 'specified', { created_at: iso(now) });
- let r = diffSpecLifecycle(state, [fresh], now);
- assert.deepEqual(r.created, ['a']);
- state = r.state;
- // branch switched away (spec gone), then back
- state = diffSpecLifecycle(state, [], now + 1000).state;
- r = diffSpecLifecycle(state, [fresh], now + 2000);
- assert.deepEqual(r.created, [], 'a returning spec is not a new one');
-});
-
-test('a spec.md added to a slug already seen counts regardless of its created_at', () => {
- let state = seed([spec('a', 'draft', { authored: false })]);
- const r = diffSpecLifecycle(state, [spec('a', 'draft')], T0 + 60_000);
- assert.deepEqual(r.created, ['a']);
-});
-
-test('a checked-out or pulled spec stamped before the last look does not count', () => {
- const state = seed([]);
- const r = diffSpecLifecycle(state, [spec('old', 'done')], T0 + 60_000);
- assert.deepEqual(r.created, []);
- assert.deepEqual(r.advanced, []);
-});
-
-test('an unreadable or date-only created_at counts — Frame cannot tell it is old', () => {
- const state = seed([]);
- const r = diffSpecLifecycle(state, [
- spec('x', 'draft', { created_at: null }),
- spec('y', 'draft', { created_at: '2026-01-01' })
- ], T0 + 60_000);
- assert.deepEqual(r.created, ['x', 'y']);
-});
-
-test('a phase counts only past the furthest one seen — regressions and ping-pong do not', () => {
- let state = seed([spec('a', 'planned')]);
- const now = T0 + 60_000;
- let r = diffSpecLifecycle(state, [spec('a', 'tasks_generated', { last_phase_at: iso(now) })], now);
- assert.deepEqual(r.advanced, [{ slug: 'a', phase: 'tasks_generated' }]);
- r = diffSpecLifecycle(r.state, [spec('a', 'planned', { last_phase_at: iso(now) })], now + 1000);
- assert.deepEqual(r.advanced, []);
- r = diffSpecLifecycle(r.state, [spec('a', 'tasks_generated', { last_phase_at: iso(now + 2000) })], now + 2000);
- assert.deepEqual(r.advanced, [], 'returning to a phase already counted');
-});
-
-test('a phase jump stamped before the last look (checkout) does not count', () => {
- const state = seed([spec('a', 'planned')]);
- const r = diffSpecLifecycle(state, [spec('a', 'done')], T0 + 60_000);
- assert.deepEqual(r.advanced, []);
-});
-
-test('a spec that never had a readable phase is not advancing when it gets one', () => {
- const state = seed([spec('a', null)]);
- const r = diffSpecLifecycle(state, [spec('a', 'draft', { last_phase_at: iso(T0 + 1000) })], T0 + 1000);
- assert.deepEqual(r.advanced, []);
-});
-
-test('a rename carries history — the new slug is not a created spec', () => {
- const state = renameSpecLifecycle(seed([spec('old-name', 'planned', { created_at: iso(T0) })]), 'old-name', 'new-name');
- const r = diffSpecLifecycle(state, [spec('new-name', 'planned', { created_at: iso(T0) })], T0 + 1000);
- assert.deepEqual(r.created, []);
- assert.deepEqual(r.advanced, []);
-});