failproofai 1.0.0 → 1.0.1-beta.1

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 +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +4 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m98kzy._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +3 -0
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0sjzdjg._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0v5_91z._.js +4 -0
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-a3135._.js +4 -0
  104. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1hvwiw8._.js → [root-of-the-server]__11z_299._.js} +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apc90t._.js → [root-of-the-server]__15c_z97._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1wmno1q._.js → [root-of-the-server]__17d_ffl._.js} +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +4 -0
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1e5g_ur._.js +4 -0
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hmqefd._.js +4 -0
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +6 -0
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zvtylo._.js +3 -0
  113. package/.next/standalone/.next/server/chunks/ssr/_04j57fb._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/ssr/_0hom1s1._.js +3 -0
  116. package/.next/standalone/.next/server/chunks/ssr/_0qk91ba._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/ssr/_1q7j05x._.js +3 -0
  118. package/.next/standalone/.next/server/chunks/ssr/_1wboum1._.js +23 -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/{2676v-lyzgo91.js → 01b0vu6t3wtfh.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +1 -0
  135. package/.next/standalone/.next/static/chunks/0w-2enf8raksk.js +1 -0
  136. package/.next/standalone/.next/static/chunks/{2kopjq_or743g.js → 13pe0rgzztjxa.js} +2 -2
  137. package/.next/standalone/.next/static/chunks/{3xvtgz6arztp_.js → 1cn6xl9lfp3ei.js} +1 -1
  138. package/.next/standalone/.next/static/chunks/{0zi8zjav5a4rq.js → 1e_2h_-zvxnke.js} +1 -1
  139. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  140. package/.next/standalone/.next/static/chunks/{0zm3_8jpqitxy.js → 1x9238y5kw9vi.js} +22 -22
  141. package/.next/standalone/.next/static/chunks/1xajksrefvav8.js +1 -0
  142. package/.next/standalone/.next/static/chunks/{1hfocb0sdl85c.js → 25c48grb44a-n.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/{2ouq44jv_0x04.js → 28bdm54bngvc7.js} +1 -1
  144. package/.next/standalone/.next/static/chunks/29-iqnp9v_axt.css +1 -0
  145. package/.next/standalone/.next/static/chunks/{31k3yxpy57_fy.js → 2hhselnqeo4ll.js} +1 -1
  146. package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +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/lib/telemetry.ts +45 -40
  165. package/.next/standalone/package.json +12 -10
  166. package/.next/standalone/server.js +1 -1
  167. package/bin/failproofai.mjs +6 -0
  168. package/bin/failproofaid-shim.mjs +0 -0
  169. package/dist/cli.mjs +1915 -472
  170. package/dist/worker.mjs +27 -5
  171. package/lib/auth/api-server-client.ts +51 -29
  172. package/lib/auth/auth-store.ts +44 -59
  173. package/lib/telemetry.ts +45 -40
  174. package/package.json +12 -10
  175. package/scripts/docs-audit.ts +493 -0
  176. package/scripts/translate-docs/cli.ts +16 -2
  177. package/scripts/translate-docs/mdx-translator.ts +6 -1
  178. package/scripts/translate-docs/readme-translator.ts +4 -2
  179. package/src/audit/cli-login.ts +330 -0
  180. package/src/audit/cli.ts +164 -31
  181. package/src/audit/dashboard-cache.ts +44 -8
  182. package/src/audit/harm-report.ts +260 -0
  183. package/src/audit/machine-store.ts +120 -0
  184. package/src/audit/redact-example.ts +312 -0
  185. package/src/audit/report-harm.ts +167 -0
  186. package/src/audit/schedule-cli.ts +352 -0
  187. package/src/hooks/builtin-policies.ts +26 -0
  188. package/src/hooks/daemon-service.ts +76 -0
  189. package/src/hooks/fp-config.ts +96 -5
  190. package/src/hooks/fp-home.ts +118 -8
  191. package/src/hooks/fp-reset.ts +79 -9
  192. package/src/hooks/hook-telemetry.ts +3 -2
  193. package/src/hooks/migrations.ts +241 -4
  194. package/src/hooks/tui.ts +61 -3
  195. package/.next/standalone/.next/server/app/api/auth/reminder/route/app-paths-manifest.json +0 -3
  196. package/.next/standalone/.next/server/app/api/auth/reminder/route/build-manifest.json +0 -12
  197. package/.next/standalone/.next/server/app/api/auth/reminder/route/server-reference-manifest.json +0 -4
  198. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +0 -9
  199. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.map +0 -5
  200. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +0 -1
  201. package/.next/standalone/.next/server/app/api/auth/reminder/route_client-reference-manifest.js +0 -3
  202. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-xggru._.js +0 -3
  203. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00ds05_._.js +0 -3
  204. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1dxagv_._.js +0 -3
  206. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fgqj1_._.js +0 -3
  207. package/.next/standalone/.next/server/chunks/_084toou._.js +0 -3
  208. package/.next/standalone/.next/server/chunks/_0g4t8c9._.js +0 -3
  209. package/.next/standalone/.next/server/chunks/_0km521a._.js +0 -3
  210. package/.next/standalone/.next/server/chunks/_0t8kefc._.js +0 -3
  211. package/.next/standalone/.next/server/chunks/_16dwkfn._.js +0 -3
  212. package/.next/standalone/.next/server/chunks/_1phm9ui._.js +0 -3
  213. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_auth_reminder_route_actions_1kjgxf8.js +0 -3
  214. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dk8r_l._.js +0 -3
  215. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kviq2m._.js +0 -3
  216. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ogyhu0._.js +0 -3
  217. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-rfu32._.js +0 -5
  218. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +0 -5
  219. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1t62w37._.js +0 -3
  220. package/.next/standalone/.next/server/chunks/ssr/_032wo7w._.js +0 -3
  221. package/.next/standalone/.next/server/chunks/ssr/_08840em._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/ssr/_0gl2ujp._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/ssr/_0j0ggkc._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/_0kbv5-q._.js +0 -3
  225. package/.next/standalone/.next/server/chunks/ssr/_0r1jgvp._.js +0 -3
  226. package/.next/standalone/.next/server/chunks/ssr/_0t5xq1b._.js +0 -3
  227. package/.next/standalone/.next/server/chunks/ssr/_0w_be0g._.js +0 -3
  228. package/.next/standalone/.next/server/chunks/ssr/_15gba46._.js +0 -5
  229. package/.next/standalone/.next/server/chunks/ssr/_1qyhb9r._.js +0 -23
  230. package/.next/standalone/.next/server/chunks/ssr/_1vel2ey._.js +0 -3
  231. package/.next/standalone/.next/static/chunks/0wd503tv801gy.js +0 -1
  232. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +0 -1
  233. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +0 -2
  234. package/.next/standalone/.next/static/chunks/2ynl0-6aqng_b.js +0 -1
  235. package/.next/standalone/app/api/auth/reminder/route.ts +0 -213
  236. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → zMC0Yqypg3GF82-3KN6ul}/_buildManifest.js +0 -0
  237. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → zMC0Yqypg3GF82-3KN6ul}/_clientMiddlewareManifest.js +0 -0
  238. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → zMC0Yqypg3GF82-3KN6ul}/_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
@@ -2,8 +2,11 @@
2
2
  * `failproofai audit` — run a local audit of your agent-CLI history, then open
3
3
  * the dashboard to view it.
4
4
  *
5
- * failproofai audit Scan, then launch the dashboard at /audit.
6
- * failproofai audit --help Show usage.
5
+ * failproofai audit Scan, then launch the dashboard at /audit.
6
+ * failproofai audit --schedule Put scans on a timer and mail the findings.
7
+ * failproofai audit --no-schedule Stop the timer.
8
+ * failproofai audit --status What this machine is scheduled to do.
9
+ * failproofai audit -h, --help Show usage.
7
10
  *
8
11
  * `runAudit()` is a pure local function (no network, no account). We run it,
9
12
  * render the same four progress stages the dashboard's RunProgress shows,
@@ -11,9 +14,10 @@
11
14
  * the bundled dashboard server and open the browser to /audit — which renders
12
15
  * instantly from that cache.
13
16
  *
14
- * No arguments yet — a bare `failproofai audit` does a full scan (all CLIs, all
15
- * history). Flags (--since, --cli, --project, --port, --no-open) are easy
16
- * follow-ups against `RunAuditOptions`.
17
+ * A bare `failproofai audit` does a full scan (all CLIs, all history); the
18
+ * scheduling flags above write config and never scan. Scan-shaping flags
19
+ * (--since, --cli, --project, --port, --no-open) are easy follow-ups against
20
+ * `RunAuditOptions`.
17
21
  *
18
22
  * `--scheduled` is the one exception, and it is not a flag on the interactive
19
23
  * command so much as a second entry point sharing its name: it runs the same
@@ -28,6 +32,7 @@ import { trackHookEvent } from "../hooks/hook-telemetry";
28
32
  import { getInstanceId } from "../../lib/telemetry-id";
29
33
  import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize";
30
34
  import { openWhenReady } from "./open-browser";
35
+ import { describeOutcome, reportHarm } from "./report-harm";
31
36
  import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM } from "../hooks/tui";
32
37
 
33
38
  /** Port the bundled dashboard binds to. Matches `scripts/launch.ts`'s default
@@ -57,29 +62,60 @@ export const AUDIT_STAGES: ReadonlyArray<{ label: string; detail: string }> = [
57
62
  { label: "aggregating results", detail: "counting hits, ranking by frequency" },
58
63
  ];
59
64
 
60
- const HELP = `
61
- failproofai audit — audit your AI agent's behavior, then open the dashboard
65
+ /** Column the description text starts at. The widest command line is 37 wide. */
66
+ const HELP_DESC_COL = 40;
62
67
 
63
- USAGE
64
- failproofai audit Scan your agent-CLI session history for risky and
65
- wasteful patterns, then open the audit dashboard.
66
- failproofai audit --help Show this help.
67
-
68
- failproofai audit --scheduled
69
- Headless run: scan, refresh the dashboard's cached
70
- result, print one line, exit. No browser, no
71
- server. This is what a scheduled audit runs; exit
72
- 75 means another audit already had the lock.
73
-
74
- WHAT IT DOES
75
- 1. Scans past sessions from every installed agent CLI (Claude, Codex, Cursor,
76
- Copilot, OpenCode, Pi) — entirely on your machine.
77
- 2. Starts the local dashboard and opens
78
- http://localhost:${DASHBOARD_PORT}/audit with your results.
68
+ /**
69
+ * `audit --help`.
70
+ *
71
+ * A function rather than a module-level string because the command names are
72
+ * coloured through `c()`, which reads `colorOn()` at CALL time — a const would
73
+ * bake in whatever the TTY looked like at import, and this module is imported
74
+ * by the bundled CLI long before anyone asks for help.
75
+ *
76
+ * `--scheduled` is deliberately absent: it is not a flag a person types but a
77
+ * second entry point the daemon spawns (see the module header), and listing a
78
+ * machine-facing flag one letter away from `--schedule` in the same block is
79
+ * how somebody ends up running a 100-second scan when they meant to configure
80
+ * one. It still works, and still refuses every argument it always refused.
81
+ */
82
+ export function helpText(): string {
83
+ const row = (command: string, lines: string[]): string => {
84
+ // Padding is measured on the RAW command — `c()` adds escape bytes that
85
+ // occupy no columns, so colouring first would misalign every row.
86
+ const pad = " ".repeat(Math.max(1, HELP_DESC_COL - 2 - command.length));
87
+ return lines
88
+ .map((line, i) =>
89
+ i === 0 ? ` ${c(CYAN, command)}${pad}${line}` : `${" ".repeat(HELP_DESC_COL)}${line}`,
90
+ )
91
+ .join("\n");
92
+ };
79
93
 
80
- Runs fully offline — no account or network required. Press Ctrl+C to stop the
81
- dashboard server when you're done.
82
- `.trimStart();
94
+ return [
95
+ `${c(BOLD, "failproofai audit")} — review your agent CLIs for risky and wasteful patterns.`,
96
+ c(DIM, "Everything runs on this machine; only a scheduled digest ever leaves it."),
97
+ "",
98
+ c(BOLD, "USAGE"),
99
+ row("failproofai audit", [
100
+ "Scan your session history, then open",
101
+ `http://localhost:${DASHBOARD_PORT}/audit`,
102
+ ]),
103
+ row("failproofai audit --schedule [days]", [
104
+ "Scan on a timer and email the findings.",
105
+ "Default 7 days, range 1-90.",
106
+ "Signs you in the first time; add",
107
+ "--email <address> to skip a prompt.",
108
+ ]),
109
+ row("failproofai audit --no-schedule", ["Stop the timer. Leaves you signed in."]),
110
+ row("failproofai audit --status", [
111
+ "Whether scheduling is on, where reports",
112
+ "go, the daemon's state, and when the",
113
+ "next scan is due.",
114
+ ]),
115
+ row("failproofai audit -h, --help", ["Show this help."]),
116
+ "",
117
+ ].join("\n");
118
+ }
83
119
 
84
120
  // ── ANSI helpers ────────────────────────────────────────────────────────────
85
121
  // Colours come from the shared brand palette in hooks/tui.ts, so `audit` reads
@@ -365,6 +401,29 @@ export async function runScheduledAudit(): Promise<number> {
365
401
  `${num(result.transcripts.scanned)} sessions, ${num(result.totals.hits)} hits\n`,
366
402
  );
367
403
 
404
+ // Report harmful findings upstream, if the user switched emailed reports on.
405
+ //
406
+ // AFTER the dashboard cache is written and AFTER the success line, because
407
+ // the scan is the product and this is an optional extra on top of it.
408
+ // `reportHarm` never throws — every failure inside it is an outcome — so a
409
+ // dead network, an expired session or an api-server having a bad day cannot
410
+ // turn a successful scan into exit 1. A machine that never opted in prints
411
+ // nothing at all and does no work here.
412
+ //
413
+ // Scheduled runs ONLY. An interactive `failproofai audit` has a person
414
+ // sitting in front of the result, so mailing it to them is noise, and it
415
+ // would also make the manual command do a network call that
416
+ // `audit --help` promises it does not.
417
+ const outcome = await reportHarm(result);
418
+ const line = describeOutcome(outcome);
419
+ if (line) {
420
+ // Anything other than a successful send goes to stderr: on a scheduled run
421
+ // the journal is the only reader, and "the email did not go out" is the
422
+ // half worth finding with a grep.
423
+ const stream = outcome.kind === "sent" ? process.stdout : process.stderr;
424
+ stream.write(`${line}\n`);
425
+ }
426
+
368
427
  return 0;
369
428
  } finally {
370
429
  attempt.lock.release();
@@ -454,25 +513,99 @@ export async function runPostSetupAudit(): Promise<void> {
454
513
 
455
514
  export async function runAuditCli(args: string[]): Promise<void> {
456
515
  if (args.includes("--help") || args.includes("-h")) {
457
- process.stdout.write(HELP);
516
+ process.stdout.write(helpText());
458
517
  process.exit(0);
459
518
  }
460
519
  // The headless path, spawned rather than typed. Handled ahead of the
461
520
  // rejection below so that adding it costs the interactive path nothing: it
462
521
  // still refuses every argument it has always refused.
522
+ //
523
+ // `--scheduled` (run one now, headlessly) and `--schedule` (put runs on a
524
+ // timer) differ by one letter and do completely different things, so this
525
+ // is checked FIRST and exactly: a `--schedule` typo must not silently start
526
+ // a 100-second scan, and `--scheduled` must never be read as configuration.
463
527
  if (args.includes("--scheduled")) {
464
528
  const extra = args.find((a) => a !== "--scheduled");
465
529
  if (extra) die(`\`audit --scheduled\` takes no other arguments (got: ${extra}).`);
466
530
  process.exit(await runScheduledAudit());
467
531
  }
468
532
 
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.
533
+ // The scheduling controls. These write config and exit; none of them scan.
534
+ if (args.includes("--status")) {
535
+ const extra = args.find((a) => a !== "--status");
536
+ if (extra) die(`\`audit --status\` takes no other arguments (got: ${extra}).`);
537
+ const { runScheduleStatus } = await import("./schedule-cli");
538
+ runScheduleStatus();
539
+ process.exit(0);
540
+ }
541
+
542
+ if (args.includes("--no-schedule")) {
543
+ const extra = args.find((a) => a !== "--no-schedule");
544
+ if (extra) die(`\`audit --no-schedule\` takes no other arguments (got: ${extra}).`);
545
+ const { runScheduleOff } = await import("./schedule-cli");
546
+ runScheduleOff();
547
+ process.exit(0);
548
+ }
549
+
550
+ const scheduleAt = args.indexOf("--schedule");
551
+ if (scheduleAt !== -1) {
552
+ // Parsed POSITIONALLY rather than by matching values against a set: an
553
+ // address and a day count are both just strings, and "have I already seen
554
+ // this string" cannot tell the argument of one flag from the argument of
555
+ // another.
556
+ let days: string | undefined;
557
+ let email: string | undefined;
558
+ for (let i = 0; i < args.length; i += 1) {
559
+ const a = args[i];
560
+ if (a === "--schedule") {
561
+ // The day count is OPTIONAL, so the next token counts only when it is
562
+ // not itself a flag — `--schedule --email x` must not read "--email"
563
+ // as a number of days.
564
+ const next = args[i + 1];
565
+ if (next !== undefined && !next.startsWith("-")) {
566
+ days = next;
567
+ i += 1;
568
+ }
569
+ continue;
570
+ }
571
+ if (a === "--email" || a.startsWith("--email=")) {
572
+ // Both forms, because both are what people type.
573
+ if (a.startsWith("--email=")) {
574
+ email = a.slice("--email=".length);
575
+ } else {
576
+ email = args[i + 1];
577
+ i += 1;
578
+ }
579
+ if (email === undefined || email.length === 0 || email.startsWith("-")) {
580
+ die("`--email` needs an address, e.g. `--email you@yourdomain.com`.");
581
+ }
582
+ continue;
583
+ }
584
+ die(`\`audit --schedule\` does not take ${a}.`);
585
+ }
586
+
587
+ const { runScheduleOn, ScheduleCliError } = await import("./schedule-cli");
588
+ const { LoginError } = await import("./cli-login");
589
+ try {
590
+ await runScheduleOn(days, email);
591
+ } catch (err) {
592
+ // Both are "the user needs to read one sentence and try again", not a
593
+ // stack trace: a wrong day count, a cancelled prompt, an api-server that
594
+ // is not running.
595
+ if (err instanceof ScheduleCliError || err instanceof LoginError) die(err.message);
596
+ throw err;
597
+ }
598
+ process.exit(0);
599
+ }
600
+
601
+ // Anything else is rejected rather than silently doing a bare audit, so a
602
+ // typo like `--sched` does not quietly scan and exit 0 looking like it worked.
471
603
  const stray = args.find((a) => a !== "--help" && a !== "-h");
472
604
  if (stray) {
473
605
  die(
474
- `\`audit\` takes no arguments yet (got: ${stray}).\n` +
475
- `Run \`failproofai audit\` to scan your history and open the dashboard.`,
606
+ `\`audit\` does not take ${stray}.\n` +
607
+ `Run \`failproofai audit\` to scan your history and open the dashboard,\n` +
608
+ `or \`failproofai audit --help\` for the scheduling commands.`,
476
609
  );
477
610
  }
478
611
 
@@ -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
  }