failproofai 1.0.0 → 1.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +0 -1
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +5 -5
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +0 -6
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +4 -3
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +4 -3
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  24. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -2
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js +3 -3
  28. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +4 -3
  32. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  36. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -3
  38. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  42. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/index.html +1 -1
  45. package/.next/standalone/.next/server/app/index.rsc +15 -15
  46. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  47. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  48. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js +4 -3
  51. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  54. package/.next/standalone/.next/server/app/policies/page.js +6 -5
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page.js +5 -4
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  67. package/.next/standalone/.next/server/app/projects/page.js +5 -4
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  71. package/.next/standalone/.next/server/app/settings/page.js +6 -4
  72. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  73. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  74. package/.next/standalone/.next/server/app-paths-manifest.json +0 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0nne229._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ny9q5d._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1hstmft._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p8poxd._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0jx6cp6._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0f-x2b0._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1f290jp._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0hwvl7e._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1g2ji1t._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1ctjdzl._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03eykvd._.js +5 -0
  95. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07175vb._.js → [root-of-the-server]__07oh8b5._.js} +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cxe_2_._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g2rg69._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m98kzy._.js +4 -0
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0sjzdjg._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0v5_91z._.js +4 -0
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-a3135._.js +4 -0
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1hvwiw8._.js → [root-of-the-server]__11z_299._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apc90t._.js → [root-of-the-server]__15c_z97._.js} +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1wmno1q._.js → [root-of-the-server]__17d_ffl._.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +4 -0
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1e5g_ur._.js +4 -0
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hmqefd._.js +4 -0
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +6 -0
  113. package/.next/standalone/.next/server/chunks/ssr/_04j57fb._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/_05enntk._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +3 -0
  116. package/.next/standalone/.next/server/chunks/ssr/_0hom1s1._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/ssr/_1nq-35m._.js +23 -0
  118. package/.next/standalone/.next/server/chunks/ssr/_1q7j05x._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  121. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  123. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +3 -0
  124. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  127. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +3 -0
  128. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  129. package/.next/standalone/.next/server/pages/404.html +1 -1
  130. package/.next/standalone/.next/server/pages/500.html +1 -1
  131. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  132. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  133. package/.next/standalone/.next/static/chunks/00q6cfzuzpc85.js +1 -0
  134. package/.next/standalone/.next/static/chunks/{0zm3_8jpqitxy.js → 054eg7y_cjyf9.js} +22 -22
  135. package/.next/standalone/.next/static/chunks/{2ouq44jv_0x04.js → 0mjufa_0xxz75.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +1 -0
  137. package/.next/standalone/.next/static/chunks/{0zi8zjav5a4rq.js → 1-oz4co31nvc5.js} +1 -1
  138. package/.next/standalone/.next/static/chunks/13--2yvqek_s1.js +1 -0
  139. package/.next/standalone/.next/static/chunks/136ldjmzxhush.js +1 -0
  140. package/.next/standalone/.next/static/chunks/18fqy3doo2ial.js +6 -0
  141. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  142. package/.next/standalone/.next/static/chunks/{31k3yxpy57_fy.js → 1o5sxp_mdaoci.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/29-iqnp9v_axt.css +1 -0
  144. package/.next/standalone/.next/static/chunks/{2676v-lyzgo91.js → 2es4z1ei90vsv.js} +1 -1
  145. package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +1 -0
  146. package/.next/standalone/.next/static/chunks/3peix2vxzj7eh.js +1 -0
  147. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +1 -0
  148. package/.next/standalone/.next/static/chunks/{0km-jn9o5xyqs.css → 3wq9x0c-7ld5d.css} +1 -1
  149. package/.next/standalone/app/actions/get-scheduled-audit.ts +65 -6
  150. package/.next/standalone/app/actions/update-scheduled-audit.ts +90 -17
  151. package/.next/standalone/app/api/auth/status/route.ts +7 -17
  152. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +2 -6
  153. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +101 -281
  154. package/.next/standalone/app/audit/_components/empty-state.tsx +2 -2
  155. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  156. package/.next/standalone/app/audit/audit-styles.css +150 -26
  157. package/.next/standalone/app/globals.css +39 -1
  158. package/.next/standalone/app/settings/page.tsx +38 -21
  159. package/.next/standalone/app/settings/settings-client.tsx +583 -392
  160. package/.next/standalone/app/settings/settings.css +354 -0
  161. package/.next/standalone/components/navbar.tsx +33 -4
  162. package/.next/standalone/lib/auth/api-server-client.ts +51 -29
  163. package/.next/standalone/lib/auth/auth-store.ts +44 -59
  164. package/.next/standalone/package.json +12 -10
  165. package/.next/standalone/server.js +1 -1
  166. package/bin/failproofai.mjs +6 -0
  167. package/bin/failproofaid-shim.mjs +0 -0
  168. package/dist/cli.mjs +1889 -426
  169. package/dist/worker.mjs +27 -5
  170. package/lib/auth/api-server-client.ts +51 -29
  171. package/lib/auth/auth-store.ts +44 -59
  172. package/package.json +12 -10
  173. package/scripts/docs-audit.ts +493 -0
  174. package/scripts/translate-docs/cli.ts +16 -2
  175. package/scripts/translate-docs/mdx-translator.ts +6 -1
  176. package/scripts/translate-docs/readme-translator.ts +4 -2
  177. package/src/audit/cli-login.ts +330 -0
  178. package/src/audit/cli.ts +124 -6
  179. package/src/audit/dashboard-cache.ts +44 -8
  180. package/src/audit/harm-report.ts +260 -0
  181. package/src/audit/machine-store.ts +120 -0
  182. package/src/audit/redact-example.ts +312 -0
  183. package/src/audit/report-harm.ts +167 -0
  184. package/src/audit/schedule-cli.ts +352 -0
  185. package/src/hooks/builtin-policies.ts +26 -0
  186. package/src/hooks/daemon-service.ts +76 -0
  187. package/src/hooks/fp-config.ts +96 -5
  188. package/src/hooks/fp-home.ts +118 -8
  189. package/src/hooks/fp-reset.ts +79 -9
  190. package/src/hooks/migrations.ts +241 -4
  191. package/src/hooks/tui.ts +61 -3
  192. package/.next/standalone/.next/server/app/api/auth/reminder/route/app-paths-manifest.json +0 -3
  193. package/.next/standalone/.next/server/app/api/auth/reminder/route/build-manifest.json +0 -12
  194. package/.next/standalone/.next/server/app/api/auth/reminder/route/server-reference-manifest.json +0 -4
  195. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +0 -9
  196. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.map +0 -5
  197. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +0 -1
  198. package/.next/standalone/.next/server/app/api/auth/reminder/route_client-reference-manifest.js +0 -3
  199. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-xggru._.js +0 -3
  200. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00ds05_._.js +0 -3
  201. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +0 -3
  202. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1dxagv_._.js +0 -3
  203. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fgqj1_._.js +0 -3
  204. package/.next/standalone/.next/server/chunks/_084toou._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/_0g4t8c9._.js +0 -3
  206. package/.next/standalone/.next/server/chunks/_0km521a._.js +0 -3
  207. package/.next/standalone/.next/server/chunks/_0t8kefc._.js +0 -3
  208. package/.next/standalone/.next/server/chunks/_16dwkfn._.js +0 -3
  209. package/.next/standalone/.next/server/chunks/_1phm9ui._.js +0 -3
  210. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_auth_reminder_route_actions_1kjgxf8.js +0 -3
  211. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dk8r_l._.js +0 -3
  212. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kviq2m._.js +0 -3
  213. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ogyhu0._.js +0 -3
  214. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-rfu32._.js +0 -5
  215. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +0 -5
  216. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1t62w37._.js +0 -3
  217. package/.next/standalone/.next/server/chunks/ssr/_032wo7w._.js +0 -3
  218. package/.next/standalone/.next/server/chunks/ssr/_08840em._.js +0 -3
  219. package/.next/standalone/.next/server/chunks/ssr/_0gl2ujp._.js +0 -3
  220. package/.next/standalone/.next/server/chunks/ssr/_0j0ggkc._.js +0 -3
  221. package/.next/standalone/.next/server/chunks/ssr/_0kbv5-q._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/ssr/_0r1jgvp._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/ssr/_0t5xq1b._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/_0w_be0g._.js +0 -3
  225. package/.next/standalone/.next/server/chunks/ssr/_15gba46._.js +0 -5
  226. package/.next/standalone/.next/server/chunks/ssr/_1qyhb9r._.js +0 -23
  227. package/.next/standalone/.next/server/chunks/ssr/_1vel2ey._.js +0 -3
  228. package/.next/standalone/.next/static/chunks/0wd503tv801gy.js +0 -1
  229. package/.next/standalone/.next/static/chunks/1hfocb0sdl85c.js +0 -1
  230. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +0 -1
  231. package/.next/standalone/.next/static/chunks/2kopjq_or743g.js +0 -6
  232. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +0 -2
  233. package/.next/standalone/.next/static/chunks/2ynl0-6aqng_b.js +0 -1
  234. package/.next/standalone/.next/static/chunks/3xvtgz6arztp_.js +0 -1
  235. package/.next/standalone/app/api/auth/reminder/route.ts +0 -213
  236. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_buildManifest.js +0 -0
  237. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_clientMiddlewareManifest.js +0 -0
  238. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_ssgManifest.js +0 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Email-OTP sign-in, in the terminal.
3
+ *
4
+ * The dashboard has had this since the beginning, as two API routes. The CLI
5
+ * had nothing, which meant the one surface that works on a headless box could
6
+ * not do the one thing scheduling requires. This is the same flow through the
7
+ * same functions — `requestLoginCode` / `verifyLoginCode` from
8
+ * `api-server-client`, `writeAuth` from `auth-store` — writing the same
9
+ * `~/.failproofai/audit/session.json`.
10
+ *
11
+ * Sharing the store is what makes "the CLI and the dashboard are always in
12
+ * sync" true by construction rather than by discipline: there is one file and
13
+ * one writer, so signing in here shows up there on the next read, and signing
14
+ * out there ends the session the scheduled audit was going to report under.
15
+ */
16
+ import { AuthApiError, requestLoginCode, verifyLoginCode } from "../../lib/auth/api-server-client";
17
+ import {
18
+ authFromTokenResponse,
19
+ readAuth,
20
+ writeAuth,
21
+ type StoredAuth,
22
+ } from "../../lib/auth/auth-store";
23
+ import {
24
+ ANSI_RESET,
25
+ BAR,
26
+ colorsEnabled,
27
+ intro,
28
+ outro,
29
+ promptText,
30
+ step,
31
+ stepOpen,
32
+ } from "../hooks/tui";
33
+
34
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
35
+
36
+ /**
37
+ * The api-server's own bound on a submitted code (`auth/models.rs`). Matched
38
+ * here so a paste that is obviously too long is rejected at the prompt, where
39
+ * it can be retyped, rather than by the server — which answers
40
+ * `validation_error` rather than `invalid_code`, a distinction the retry loop
41
+ * below treats as fatal.
42
+ */
43
+ const CODE_MIN = 4;
44
+ const CODE_MAX = 12;
45
+
46
+ /**
47
+ * The spine a prompt hangs off, so the two questions sit inside the same frame
48
+ * `intro`/`outro` draw. Empty when colour is off or output is piped: the frame
49
+ * is decoration, and a log file should not collect box-drawing characters.
50
+ */
51
+ function spine(): string {
52
+ return colorsEnabled(process.stdout)
53
+ ? `${ANSI_DIM_BAR}${BAR}${ANSI_RESET} `
54
+ : "";
55
+ }
56
+ const ANSI_DIM_BAR = "\x1B[2m";
57
+
58
+ /**
59
+ * Codes are numeric (`auth/otp.rs` generates digits only), so anything else in
60
+ * the field is packaging: "Your failproof code is 123456", a copied "123 456",
61
+ * a stray trailing space from a double-click selection. Keeping the digits is
62
+ * the difference between a paste that works and one that costs a fresh email.
63
+ *
64
+ * Applied only when the input HAS digits and something else — a field of pure
65
+ * digits passes through untouched, so a genuinely wrong code is still reported
66
+ * as wrong rather than silently rewritten into a different one.
67
+ */
68
+ export function extractCode(raw: string): string {
69
+ const trimmed = raw.trim();
70
+ if (/^\d+$/.test(trimmed)) return trimmed;
71
+ const runs = trimmed.match(/\d+/g) ?? [];
72
+ if (runs.length === 0) return trimmed;
73
+ // A run long enough to BE a code wins outright.
74
+ //
75
+ // Joining every digit in the line was the whole rule, and the prompt's own
76
+ // hint ("paste the whole line if you like") walks straight into it: the real
77
+ // message reads `Your failproof code is 123456 (expires in 10 minutes)`, so
78
+ // the join produced `12345610` — eight digits, which passes the 4–12
79
+ // validator, reaches the server, and burns an attempt on a code nobody typed.
80
+ // Anything the sentence adds after the code is short; the code is not.
81
+ const whole = runs.find((run) => run.length >= CODE_MIN);
82
+ if (whole) return whole;
83
+ // Otherwise the digits really are split — a copied `123 456` — and joining
84
+ // them is the reconstruction that was always intended.
85
+ return runs.join("");
86
+ }
87
+
88
+ export interface SignedIn {
89
+ id: string;
90
+ email: string;
91
+ }
92
+
93
+ /**
94
+ * Whether a sign-in flow actually ran.
95
+ *
96
+ * The caller uses it to decide whether its confirmation continues an open frame
97
+ * or stands on its own: the `│` spine means "a flow is happening", so printing
98
+ * one under a command that answered instantly from the session file would be a
99
+ * frame with no beginning.
100
+ */
101
+ export interface EnsureSignedIn {
102
+ user: SignedIn;
103
+ prompted: boolean;
104
+ }
105
+
106
+ export class LoginError extends Error {}
107
+
108
+ /**
109
+ * Whether this terminal can run an interactive prompt.
110
+ *
111
+ * Checked BEFORE anything is written, so a CI runner or a cron line gets one
112
+ * clear sentence instead of a hang on a `readline` nobody will ever answer.
113
+ */
114
+ export function canPrompt(): boolean {
115
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
116
+ }
117
+
118
+ /**
119
+ * The stored session, unless its refresh token has already expired.
120
+ *
121
+ * The check is a comparison against a number that is already in the file, so it
122
+ * keeps the offline-friendly property the doc below argues for: no request, no
123
+ * network failure mode, nothing new that a dropped wifi connection can break.
124
+ * What it removes is the case where every one of those is fine and the session
125
+ * is simply dead — revoked from another machine, or past its refresh window.
126
+ *
127
+ * Without it `--schedule` printed `reports to <email>` and exited 0 on a
128
+ * session that cannot mint another access token, so the user configured
129
+ * digests, was shown the destination, and then heard nothing for up to a full
130
+ * interval (90 days at the maximum) with the only signal a line in the journal.
131
+ * The dashboard already refuses this exact state in `setAutoAuditAction`, so
132
+ * the two surfaces disagreed on the one thing this feature claims is in sync.
133
+ *
134
+ * Expiry is treated as "sign in again", not as an error: the OTP prompt below
135
+ * is the remedy, and falling through to it is what makes this recoverable in
136
+ * one command instead of needing the file removed by hand.
137
+ */
138
+ function sessionStillValid(auth: StoredAuth | null): StoredAuth | null {
139
+ if (!auth) return null;
140
+ // Seconds, per StoredAuth. A file whose value is missing was normalised to
141
+ // `access_expires_at` by `readAuth`, so this is always a real number.
142
+ return auth.refresh_expires_at * 1000 > Date.now() ? auth : null;
143
+ }
144
+
145
+ /**
146
+ * Return the current session, or run the OTP flow to create one.
147
+ *
148
+ * Deliberately NOT `whoAmI()`: that round-trips to `/v0/auth/me` and returns
149
+ * null on a network blip, which here would mean re-prompting a signed-in user
150
+ * for a code because their wifi dropped. The local file is the source of truth
151
+ * for "is somebody signed in on this machine"; whether the token still works is
152
+ * the reporting path's problem, and it already handles that by pausing digests
153
+ * rather than failing.
154
+ */
155
+ export async function ensureSignedIn(preset?: string): Promise<EnsureSignedIn> {
156
+ const existing = sessionStillValid(readAuth());
157
+ if (existing) {
158
+ // A machine already reports as somebody. An `--email` naming a DIFFERENT
159
+ // address is refused rather than honoured: silently re-pointing where a
160
+ // machine's digests go is the kind of change nobody notices until they
161
+ // stop arriving, and the flag reads as "sign me in", not "switch accounts".
162
+ if (preset && !sameAddress(preset, existing.user.email)) {
163
+ throw new LoginError(
164
+ `This machine is already signed in as ${existing.user.email}.\n` +
165
+ `To report as ${preset.trim().toLowerCase()} instead, sign out first — ` +
166
+ `from the dashboard, or by removing ~/.failproofai/audit/session.json.`,
167
+ );
168
+ }
169
+ return { user: existing.user, prompted: false };
170
+ }
171
+
172
+ if (!canPrompt()) {
173
+ throw new LoginError(
174
+ "Signing in needs an interactive terminal, and this one is not.\n" +
175
+ "Run `failproofai audit --schedule` from a shell you are sitting at,\n" +
176
+ "or sign in from the dashboard — both write the same session file.",
177
+ );
178
+ }
179
+
180
+ return { user: await runLogin(preset), prompted: true };
181
+ }
182
+
183
+ /** Addresses compare case-insensitively, because a mail server does. */
184
+ function sameAddress(a: string, b: string): boolean {
185
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
186
+ }
187
+
188
+ /**
189
+ * Reject an address the flag supplied before anything is drawn or sent.
190
+ *
191
+ * Returned as a message rather than thrown so the caller can fail at the CLI
192
+ * boundary, next to where the day count is checked — a typo'd flag should look
193
+ * like a usage error, not like a sign-in that opened a frame and gave up.
194
+ */
195
+ export function invalidEmail(address: string): string | null {
196
+ return EMAIL_RE.test(address.trim())
197
+ ? null
198
+ : `\`--email\` needs an email address (got: ${address}).`;
199
+ }
200
+
201
+ /**
202
+ * The two prompts, inside the frame `failproofai config` uses.
203
+ *
204
+ * Same logo, same `│` spine, same `◆ / ◇` step glyphs, same pink `└` close —
205
+ * because this is the same product asking, and a sign-in that looked like a
206
+ * different tool would be the one moment the seam showed. It is also the only
207
+ * moment this command asks for something personal, which is the moment worth
208
+ * spending the frame on.
209
+ *
210
+ * Exported so a test can drive it without the caller.
211
+ */
212
+ export async function runLogin(preset?: string): Promise<SignedIn> {
213
+ intro("scheduled audits need somewhere to send the report");
214
+
215
+ let address: string;
216
+ if (preset) {
217
+ // Supplied on the command line, so the question is already answered — but
218
+ // it is still SHOWN, as a settled step, because it is the address a code is
219
+ // about to be sent to and the flag is exactly where a typo hides.
220
+ address = preset.trim().toLowerCase();
221
+ step("your email", address);
222
+ } else {
223
+ const email = await promptText({
224
+ prefix: spine(),
225
+ message: "your email",
226
+ hint: "you@yourdomain.com",
227
+ validate: (v) => (EMAIL_RE.test(v.trim()) ? null : "that doesn't look like an email"),
228
+ });
229
+ if (email === null) {
230
+ outro("Cancelled — nothing was changed.", { ok: false });
231
+ throw new LoginError("Cancelled.");
232
+ }
233
+ address = email.trim().toLowerCase();
234
+ }
235
+ let expiresInMin = 10;
236
+ try {
237
+ const sent = await requestLoginCode(address);
238
+ expiresInMin = Math.max(1, Math.ceil(sent.expires_in / 60));
239
+ } catch (err) {
240
+ outro("Could not send a login code.", { ok: false });
241
+ throw new LoginError(describeAuthError(err, "Could not send a login code"));
242
+ }
243
+
244
+ // The address is echoed back on the settled step rather than left to memory:
245
+ // a typo in it is the single most likely reason no code arrives, and this is
246
+ // the last place it can be noticed before somebody starts waiting.
247
+ step("code sent", `to ${address} · expires in ${expiresInMin} min`);
248
+
249
+ // Three attempts, matching the server's own per-code cap. Looping forever
250
+ // would keep a person typing at a code the server stopped accepting after
251
+ // the fifth try, and one attempt would punish a typo with a fresh email.
252
+ const ATTEMPTS = 3;
253
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
254
+ stepOpen(attempt === 1 ? "the code from that email" : "try that code again");
255
+ const typed = await promptText({
256
+ prefix: spine(),
257
+ message: "code",
258
+ // Unmasked on purpose. A login code is single-use and expires in minutes,
259
+ // so hiding it protects nothing and costs the one thing that matters at
260
+ // this prompt: seeing your own typo before pressing enter.
261
+ hint:
262
+ attempt === 1
263
+ ? "123456 · paste the whole line if you like"
264
+ : `attempt ${attempt} of ${ATTEMPTS}`,
265
+ validate: (v) => {
266
+ // No digits at all is not a mistyped code, it is not a code — caught
267
+ // here rather than spent as one of the server's five attempts.
268
+ if (!/\d/.test(v)) return "a code is digits — paste the line from the email";
269
+ const code = extractCode(v);
270
+ if (code.length < CODE_MIN) return "that looks too short to be the code";
271
+ if (code.length > CODE_MAX) return "that looks too long — paste just the code";
272
+ return null;
273
+ },
274
+ });
275
+ if (typed === null) {
276
+ outro("Cancelled — nothing was changed.", { ok: false });
277
+ throw new LoginError("Cancelled.");
278
+ }
279
+
280
+ try {
281
+ const tokens = await verifyLoginCode(address, extractCode(typed));
282
+ writeAuth(authFromTokenResponse(tokens));
283
+ step("signed in", tokens.user.email);
284
+ return { id: tokens.user.id, email: tokens.user.email };
285
+ } catch (err) {
286
+ const wrongCode = err instanceof AuthApiError && err.code === "invalid_code";
287
+ if (wrongCode && attempt < ATTEMPTS) {
288
+ step(
289
+ "that code was wrong or expired",
290
+ `${ATTEMPTS - attempt} more ${ATTEMPTS - attempt === 1 ? "try" : "tries"} before it asks for a new one`,
291
+ );
292
+ continue;
293
+ }
294
+ outro("Could not verify that code.", { ok: false });
295
+ throw new LoginError(describeAuthError(err, "Could not verify that code"));
296
+ }
297
+ }
298
+ outro("Too many wrong codes.", { ok: false });
299
+ throw new LoginError("Too many wrong codes. Run the command again for a fresh one.");
300
+ }
301
+
302
+ /**
303
+ * A sentence a person can act on.
304
+ *
305
+ * `AuthApiError` carries the server's own code, and two of them have a remedy
306
+ * worth naming rather than passing through: a rate limit is a wait, and an
307
+ * unreachable server on a machine pointed at localhost is almost always an
308
+ * api-server that is not running.
309
+ */
310
+ function describeAuthError(err: unknown, prefix: string): string {
311
+ if (err instanceof AuthApiError) {
312
+ if (err.code === "rate_limited") {
313
+ const wait = err.retryAfterSecs;
314
+ return `${prefix}: too many attempts.${wait ? ` Try again in ${wait}s.` : ""}`;
315
+ }
316
+ if (err.status === 0 || err.code === "timeout") {
317
+ return (
318
+ `${prefix}: the api-server did not respond.\n` +
319
+ `It is at ${apiBaseForMessage()} — check that it is running,\n` +
320
+ `or set FAILPROOF_API_URL to point somewhere else.`
321
+ );
322
+ }
323
+ return `${prefix}: ${err.message}`;
324
+ }
325
+ return `${prefix}: ${err instanceof Error ? err.message : String(err)}`;
326
+ }
327
+
328
+ function apiBaseForMessage(): string {
329
+ return process.env.FAILPROOF_API_URL ?? process.env.FAILPROOFAI_API_URL ?? "https://api.befailproof.ai";
330
+ }
package/src/audit/cli.ts CHANGED
@@ -28,6 +28,7 @@ import { trackHookEvent } from "../hooks/hook-telemetry";
28
28
  import { getInstanceId } from "../../lib/telemetry-id";
29
29
  import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize";
30
30
  import { openWhenReady } from "./open-browser";
31
+ import { describeOutcome, reportHarm } from "./report-harm";
31
32
  import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM } from "../hooks/tui";
32
33
 
33
34
  /** Port the bundled dashboard binds to. Matches `scripts/launch.ts`'s default
@@ -71,14 +72,34 @@ USAGE
71
72
  server. This is what a scheduled audit runs; exit
72
73
  75 means another audit already had the lock.
73
74
 
75
+ SCHEDULING
76
+ failproofai audit --schedule [days] [--email you@yourdomain.com]
77
+ Scan on a timer in the background (default 7 days,
78
+ 1-90), and email you when a scan finds something
79
+ harmful. Signs you in the first time — the report
80
+ has to go somewhere. Pass --email to answer that
81
+ up front and go straight to entering the code.
82
+ failproofai audit --no-schedule
83
+ Stop scanning on a timer. Leaves you signed in.
84
+ failproofai audit --status
85
+ What this machine is doing: whether scheduling is
86
+ on, where reports go, the daemon's state, and when
87
+ the next scan is due.
88
+
89
+ These write the same ~/.failproofai/config.json the dashboard's settings page
90
+ writes, through the same function — so the two are always in step.
91
+
74
92
  WHAT IT DOES
75
93
  1. Scans past sessions from every installed agent CLI (Claude, Codex, Cursor,
76
94
  Copilot, OpenCode, Pi) — entirely on your machine.
77
95
  2. Starts the local dashboard and opens
78
96
  http://localhost:${DASHBOARD_PORT}/audit with your results.
79
97
 
80
- Runs fully offline no account or network required. Press Ctrl+C to stop the
81
- dashboard server when you're done.
98
+ A bare "failproofai audit" needs no account, and nothing from your sessions
99
+ leaves this machine — anonymous usage counts still apply unless you set
100
+ FAILPROOFAI_TELEMETRY_DISABLED=1. Scheduling is the exception: it emails you
101
+ what it finds, so it needs an address.
102
+ Press Ctrl+C to stop the dashboard server when you're done.
82
103
  `.trimStart();
83
104
 
84
105
  // ── ANSI helpers ────────────────────────────────────────────────────────────
@@ -365,6 +386,29 @@ export async function runScheduledAudit(): Promise<number> {
365
386
  `${num(result.transcripts.scanned)} sessions, ${num(result.totals.hits)} hits\n`,
366
387
  );
367
388
 
389
+ // Report harmful findings upstream, if the user switched emailed reports on.
390
+ //
391
+ // AFTER the dashboard cache is written and AFTER the success line, because
392
+ // the scan is the product and this is an optional extra on top of it.
393
+ // `reportHarm` never throws — every failure inside it is an outcome — so a
394
+ // dead network, an expired session or an api-server having a bad day cannot
395
+ // turn a successful scan into exit 1. A machine that never opted in prints
396
+ // nothing at all and does no work here.
397
+ //
398
+ // Scheduled runs ONLY. An interactive `failproofai audit` has a person
399
+ // sitting in front of the result, so mailing it to them is noise, and it
400
+ // would also make the manual command do a network call that
401
+ // `audit --help` promises it does not.
402
+ const outcome = await reportHarm(result);
403
+ const line = describeOutcome(outcome);
404
+ if (line) {
405
+ // Anything other than a successful send goes to stderr: on a scheduled run
406
+ // the journal is the only reader, and "the email did not go out" is the
407
+ // half worth finding with a grep.
408
+ const stream = outcome.kind === "sent" ? process.stdout : process.stderr;
409
+ stream.write(`${line}\n`);
410
+ }
411
+
368
412
  return 0;
369
413
  } finally {
370
414
  attempt.lock.release();
@@ -460,19 +504,93 @@ export async function runAuditCli(args: string[]): Promise<void> {
460
504
  // The headless path, spawned rather than typed. Handled ahead of the
461
505
  // rejection below so that adding it costs the interactive path nothing: it
462
506
  // still refuses every argument it has always refused.
507
+ //
508
+ // `--scheduled` (run one now, headlessly) and `--schedule` (put runs on a
509
+ // timer) differ by one letter and do completely different things, so this
510
+ // is checked FIRST and exactly: a `--schedule` typo must not silently start
511
+ // a 100-second scan, and `--scheduled` must never be read as configuration.
463
512
  if (args.includes("--scheduled")) {
464
513
  const extra = args.find((a) => a !== "--scheduled");
465
514
  if (extra) die(`\`audit --scheduled\` takes no other arguments (got: ${extra}).`);
466
515
  process.exit(await runScheduledAudit());
467
516
  }
468
517
 
469
- // No arguments supported yet reject typos rather than silently doing a bare
470
- // audit, so a future `failproofai audit --since 7d` doesn't quietly no-op.
518
+ // The scheduling controls. These write config and exit; none of them scan.
519
+ if (args.includes("--status")) {
520
+ const extra = args.find((a) => a !== "--status");
521
+ if (extra) die(`\`audit --status\` takes no other arguments (got: ${extra}).`);
522
+ const { runScheduleStatus } = await import("./schedule-cli");
523
+ runScheduleStatus();
524
+ process.exit(0);
525
+ }
526
+
527
+ if (args.includes("--no-schedule")) {
528
+ const extra = args.find((a) => a !== "--no-schedule");
529
+ if (extra) die(`\`audit --no-schedule\` takes no other arguments (got: ${extra}).`);
530
+ const { runScheduleOff } = await import("./schedule-cli");
531
+ runScheduleOff();
532
+ process.exit(0);
533
+ }
534
+
535
+ const scheduleAt = args.indexOf("--schedule");
536
+ if (scheduleAt !== -1) {
537
+ // Parsed POSITIONALLY rather than by matching values against a set: an
538
+ // address and a day count are both just strings, and "have I already seen
539
+ // this string" cannot tell the argument of one flag from the argument of
540
+ // another.
541
+ let days: string | undefined;
542
+ let email: string | undefined;
543
+ for (let i = 0; i < args.length; i += 1) {
544
+ const a = args[i];
545
+ if (a === "--schedule") {
546
+ // The day count is OPTIONAL, so the next token counts only when it is
547
+ // not itself a flag — `--schedule --email x` must not read "--email"
548
+ // as a number of days.
549
+ const next = args[i + 1];
550
+ if (next !== undefined && !next.startsWith("-")) {
551
+ days = next;
552
+ i += 1;
553
+ }
554
+ continue;
555
+ }
556
+ if (a === "--email" || a.startsWith("--email=")) {
557
+ // Both forms, because both are what people type.
558
+ if (a.startsWith("--email=")) {
559
+ email = a.slice("--email=".length);
560
+ } else {
561
+ email = args[i + 1];
562
+ i += 1;
563
+ }
564
+ if (email === undefined || email.length === 0 || email.startsWith("-")) {
565
+ die("`--email` needs an address, e.g. `--email you@yourdomain.com`.");
566
+ }
567
+ continue;
568
+ }
569
+ die(`\`audit --schedule\` does not take ${a}.`);
570
+ }
571
+
572
+ const { runScheduleOn, ScheduleCliError } = await import("./schedule-cli");
573
+ const { LoginError } = await import("./cli-login");
574
+ try {
575
+ await runScheduleOn(days, email);
576
+ } catch (err) {
577
+ // Both are "the user needs to read one sentence and try again", not a
578
+ // stack trace: a wrong day count, a cancelled prompt, an api-server that
579
+ // is not running.
580
+ if (err instanceof ScheduleCliError || err instanceof LoginError) die(err.message);
581
+ throw err;
582
+ }
583
+ process.exit(0);
584
+ }
585
+
586
+ // Anything else is rejected rather than silently doing a bare audit, so a
587
+ // typo like `--sched` does not quietly scan and exit 0 looking like it worked.
471
588
  const stray = args.find((a) => a !== "--help" && a !== "-h");
472
589
  if (stray) {
473
590
  die(
474
- `\`audit\` takes no arguments yet (got: ${stray}).\n` +
475
- `Run \`failproofai audit\` to scan your history and open the dashboard.`,
591
+ `\`audit\` does not take ${stray}.\n` +
592
+ `Run \`failproofai audit\` to scan your history and open the dashboard,\n` +
593
+ `or \`failproofai audit --help\` for the scheduling commands.`,
476
594
  );
477
595
  }
478
596
 
@@ -126,14 +126,38 @@ export function isCacheStale(cachedAt: string, maxAgeMinutes: number = DEFAULT_M
126
126
  }
127
127
 
128
128
  /**
129
- * Read just the `cachedAt` timestamp from the dashboard cache file,
130
- * **bypassing** the TTL check. Used by the empty-state path to tell apart
131
- * "no audit has ever run" from "your last audit aged out". A non-null
132
- * return whose age exceeds `DASHBOARD_CACHE_TTL_MINUTES` means the cache
133
- * was rejected by `readDashboardCache()` for being expired (rather than
134
- * missing or schema-incompatible).
129
+ * What the last audit on this machine found, **bypassing** the TTL check.
130
+ *
131
+ * Used by the empty-state path to tell apart "no audit has ever run" from
132
+ * "your last audit aged out" — a non-null return whose age exceeds
133
+ * `DASHBOARD_CACHE_TTL_MINUTES` means `readDashboardCache()` rejected it for
134
+ * being expired rather than missing or schema-incompatible — and by /settings
135
+ * to draw its LAST SCAN and FINDINGS stats.
136
+ *
137
+ * Those stats deliberately read from HERE and not from `readDashboardCache()`.
138
+ * That reader drops an entry once it passes the TTL, which is right for a
139
+ * dashboard rendering results and exactly backwards for a stat whose subject is
140
+ * how long ago the scan was: a page that answers "when did this last run" must
141
+ * not lose the answer for the crime of it having been a while. Reading the
142
+ * timestamp from one function and the counts from the other is the specific bug
143
+ * this shape prevents — "6 days ago" beside a blank findings cell, because the
144
+ * two disagreed about whether the same file existed.
145
+ *
146
+ * The counts come from the same object the /audit page renders, so they cannot
147
+ * drift from it: `totals.hits` is the headline finding count and
148
+ * `transcripts.scanned` the sessions behind it.
135
149
  */
136
- export function readDashboardCacheMeta(): { cachedAt: string } | null {
150
+ export interface DashboardCacheSummary {
151
+ cachedAt: string;
152
+ /** `AuditResult.totals.hits` — total policy hits in that scan. */
153
+ findings: number | null;
154
+ /** `AuditResult.transcripts.scanned` — session transcripts walked. */
155
+ sessionsScanned: number | null;
156
+ /** `AuditResult.eventsScanned` — normalized tool-use events walked. */
157
+ eventsScanned: number | null;
158
+ }
159
+
160
+ export function readDashboardCacheMeta(): DashboardCacheSummary | null {
137
161
  const cachePath = getCachePath();
138
162
  if (!existsSync(cachePath)) return null;
139
163
  try {
@@ -150,7 +174,19 @@ export function readDashboardCacheMeta(): { cachedAt: string } | null {
150
174
  || typeof entry.cachedAt !== "string"
151
175
  || Number.isNaN(new Date(entry.cachedAt).getTime())
152
176
  ) return null;
153
- return { cachedAt: entry.cachedAt };
177
+ // The counts are read defensively rather than assumed: `schemaVersion`
178
+ // guards the ENTRY's shape, and a torn or hand-edited `result` can still
179
+ // arrive with a missing field. A null count renders as "—" (unknown), which
180
+ // is a different claim from 0 (scanned, found nothing) — collapsing the two
181
+ // would report a clean machine on a file we failed to read.
182
+ const result = entry.result;
183
+ const count = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
184
+ return {
185
+ cachedAt: entry.cachedAt,
186
+ findings: count(result?.totals?.hits),
187
+ sessionsScanned: count(result?.transcripts?.scanned),
188
+ eventsScanned: count(result?.eventsScanned),
189
+ };
154
190
  } catch {
155
191
  return null;
156
192
  }