failproofai 1.0.0-beta.2 → 1.0.0-beta.21

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 (381) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -1
  3. package/.next/standalone/.next/build-manifest.json +6 -6
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +6 -0
  7. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  8. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js +3 -1
  10. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  16. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  20. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page.js +3 -1
  22. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  25. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  27. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  28. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  31. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  33. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
  35. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/audit/status/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -1
  39. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +3 -2
  41. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  43. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +3 -2
  45. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -2
  47. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  53. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/index.html +1 -1
  56. package/.next/standalone/.next/server/app/index.rsc +15 -15
  57. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  58. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  59. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  60. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  61. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  63. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/page.js +3 -1
  65. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  68. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  69. package/.next/standalone/.next/server/app/policies/page.js +5 -3
  70. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  73. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  74. package/.next/standalone/.next/server/app/project/[name]/page.js +4 -2
  75. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  76. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  77. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  78. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  79. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  80. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  81. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  82. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  83. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  84. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  85. package/.next/standalone/.next/server/app/projects/page.js +4 -2
  86. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/app/settings/page/app-paths-manifest.json +3 -0
  89. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +18 -0
  90. package/.next/standalone/.next/server/app/settings/page/next-font-manifest.json +6 -0
  91. package/.next/standalone/.next/server/app/settings/page/react-loadable-manifest.json +1 -0
  92. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +53 -0
  93. package/.next/standalone/.next/server/app/settings/page.js +18 -0
  94. package/.next/standalone/.next/server/app/settings/page.js.map +5 -0
  95. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -0
  96. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +3 -0
  97. package/.next/standalone/.next/server/app-paths-manifest.json +2 -1
  98. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  99. package/.next/standalone/.next/server/chunks/{[externals]__1nl3dvw._.js → [externals]__0r-zamh._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l4vuqm._.js → [root-of-the-server]__00ni9za._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +3 -0
  103. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0-dh5jy._.js → [root-of-the-server]__0effm9m._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g0vcwt._.js +3 -0
  109. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__18d-9oi._.js → [root-of-the-server]__0ix8gm7._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0h3lfmp._.js → [root-of-the-server]__0ji11a-._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__10oa-v9._.js → [root-of-the-server]__0ojwjxl._.js} +2 -2
  112. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__0pbp9fe._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0sb_5m8._.js → [root-of-the-server]__131ebum._.js} +4 -4
  116. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +3 -0
  122. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +5 -5
  123. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  124. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  125. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-wfhrp._.js +3 -0
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06bp_fg._.js +5 -0
  129. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__074qmkp._.js +3 -0
  130. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0_1tiya._.js} +2 -2
  131. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  132. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t281d4._.js +3 -0
  133. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +5 -0
  134. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__1n2vcv4._.js} +2 -2
  135. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  136. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +3 -0
  137. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tlvv9a._.js +3 -0
  138. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +4 -0
  139. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +3 -0
  140. package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +3 -0
  141. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +3 -0
  142. package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +3 -0
  143. package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +3 -0
  144. package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +3 -0
  145. package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
  146. package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +3 -0
  147. package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +3 -0
  148. package/.next/standalone/.next/server/chunks/ssr/_1ctse6u._.js +3 -0
  149. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  150. package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +3 -0
  151. package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +3 -0
  152. package/.next/standalone/.next/server/chunks/ssr/_1sdgprl._.js +5 -0
  153. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  154. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  155. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  156. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dwo94s.js +4 -0
  157. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  158. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +3 -0
  159. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  160. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  161. package/.next/standalone/.next/server/middleware.js +2 -2
  162. package/.next/standalone/.next/server/pages/404.html +1 -1
  163. package/.next/standalone/.next/server/pages/500.html +1 -1
  164. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  165. package/.next/standalone/.next/server/server-reference-manifest.json +74 -20
  166. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  167. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  168. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  169. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 113g8wtal6_c4.js} +22 -22
  170. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  171. package/.next/standalone/.next/static/chunks/18wqn0_3mzylf.js +1 -0
  172. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +1 -0
  173. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 1xq1m8h14tnlj.js} +1 -1
  174. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 1y3vwogqb88dx.js} +1 -1
  175. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 1zmh-b0ulrqu7.js} +1 -1
  176. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  177. package/.next/standalone/.next/static/chunks/299lhw60e8gr6.js +1 -0
  178. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 2adbpdaxp5ygq.js} +1 -1
  179. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 2kbqcw-6swmav.js} +1 -1
  180. package/.next/standalone/.next/static/chunks/2n9n5m0963pdg.js +6 -0
  181. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +2 -0
  182. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 30nrrpwymht5m.js} +1 -1
  183. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  184. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  185. package/.next/standalone/SECURITY.md +8 -2
  186. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  187. package/.next/standalone/app/actions/get-hooks-config.ts +2 -2
  188. package/.next/standalone/app/actions/get-scheduled-audit.ts +70 -0
  189. package/.next/standalone/app/actions/update-scheduled-audit.ts +44 -0
  190. package/.next/standalone/app/api/audit/run/route.ts +28 -0
  191. package/.next/standalone/app/api/audit/status/route.ts +13 -1
  192. package/.next/standalone/app/api/auth/status/route.ts +1 -1
  193. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -3
  194. package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +1 -1
  195. package/.next/standalone/app/audit/_components/auth-dialog.css +159 -0
  196. package/.next/standalone/app/audit/_components/auth-dialog.tsx +3 -0
  197. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +1 -1
  198. package/.next/standalone/app/audit/_components/empty-state.tsx +0 -1
  199. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +1 -1
  200. package/.next/standalone/app/audit/_components/quirks-section.tsx +0 -1
  201. package/.next/standalone/app/audit/_components/report-footer.tsx +0 -2
  202. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  203. package/.next/standalone/app/audit/_components/strengths-section.tsx +0 -1
  204. package/.next/standalone/app/audit/audit-styles.css +6 -149
  205. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  206. package/.next/standalone/app/components/raw-log-viewer.tsx +1 -1
  207. package/.next/standalone/app/components/toast.tsx +1 -1
  208. package/.next/standalone/app/policies/hooks-client.tsx +84 -8
  209. package/.next/standalone/app/settings/page.tsx +31 -0
  210. package/.next/standalone/app/settings/settings-client.tsx +487 -0
  211. package/.next/standalone/components/navbar.tsx +2 -0
  212. package/.next/standalone/lib/auth/api-server-client.ts +2 -3
  213. package/.next/standalone/lib/auth/auth-store.ts +4 -5
  214. package/.next/standalone/lib/codex-sessions.ts +2 -1
  215. package/.next/standalone/lib/dashboard-host.ts +101 -0
  216. package/.next/standalone/lib/install-check.ts +22 -7
  217. package/.next/standalone/lib/pi-sessions.ts +99 -14
  218. package/.next/standalone/lib/telemetry-enabled.ts +54 -0
  219. package/.next/standalone/lib/telemetry-id.ts +52 -1
  220. package/.next/standalone/lib/telemetry.ts +8 -4
  221. package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
  222. package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
  223. package/.next/standalone/node_modules/react/package.json +3 -3
  224. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
  225. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
  226. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
  227. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
  228. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
  229. package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
  230. package/.next/standalone/node_modules/react-dom/package.json +4 -4
  231. package/.next/standalone/package.json +23 -12
  232. package/.next/standalone/proxy.ts +111 -1
  233. package/.next/standalone/server.js +1 -1
  234. package/bin/failproofai-worker.mjs +1 -1
  235. package/bin/failproofai.mjs +1041 -81
  236. package/bin/failproofaid-shim.mjs +4 -4
  237. package/dist/cli.mjs +18513 -10474
  238. package/dist/worker.mjs +1223 -299
  239. package/lib/auth/api-server-client.ts +2 -3
  240. package/lib/auth/auth-store.ts +4 -5
  241. package/lib/codex-sessions.ts +2 -1
  242. package/lib/dashboard-host.ts +101 -0
  243. package/lib/install-check.ts +22 -7
  244. package/lib/pi-sessions.ts +99 -14
  245. package/lib/telemetry-enabled.ts +54 -0
  246. package/lib/telemetry-id.ts +52 -1
  247. package/lib/telemetry.ts +8 -4
  248. package/package.json +23 -12
  249. package/scripts/build-daemon-packages.mjs +247 -0
  250. package/scripts/daemon-platforms.mjs +42 -0
  251. package/scripts/dev-hook.mjs +46 -5
  252. package/scripts/install-telemetry.mjs +8 -1
  253. package/scripts/launch.ts +25 -3
  254. package/scripts/parse-script-args.ts +27 -1
  255. package/scripts/prune-standalone.mjs +26 -0
  256. package/scripts/publish-aliases.mjs +62 -41
  257. package/scripts/repro-npm-install.sh +155 -0
  258. package/scripts/translate-docs/cache.ts +1 -1
  259. package/scripts/translate-docs/cli.ts +2 -2
  260. package/scripts/translate-docs/mintlify-nav.ts +1 -1
  261. package/scripts/translate-docs/readme-translator.ts +163 -4
  262. package/scripts/validate-mdx.ts +153 -13
  263. package/src/audit/audit-lock.ts +285 -0
  264. package/src/audit/audit-schedule.ts +111 -0
  265. package/src/audit/cache.ts +18 -5
  266. package/src/audit/cli-adapters/goose.ts +2 -1
  267. package/src/audit/cli-adapters/hermes.ts +28 -10
  268. package/src/audit/cli.ts +191 -35
  269. package/src/audit/dashboard-cache.ts +2 -3
  270. package/src/audit/report.ts +0 -1
  271. package/src/hooks/backfill-cli.ts +247 -0
  272. package/src/hooks/builtin-policies.ts +391 -3
  273. package/src/hooks/cloud-connection.ts +354 -0
  274. package/src/hooks/cloud-enrollment-cli.ts +439 -0
  275. package/src/hooks/cloud-enrollment.ts +284 -0
  276. package/src/hooks/cloud-introspect.ts +147 -0
  277. package/src/hooks/cloud-managed-policies.ts +206 -0
  278. package/src/hooks/collector-config.ts +218 -0
  279. package/src/hooks/configure-wizard.ts +1211 -233
  280. package/src/hooks/custom-hooks-loader.ts +243 -37
  281. package/src/hooks/daemon-client.ts +136 -33
  282. package/src/hooks/daemon-download.ts +264 -31
  283. package/src/hooks/daemon-service.ts +1124 -76
  284. package/src/hooks/first-run-gate.ts +50 -0
  285. package/src/hooks/flush-cli.ts +180 -0
  286. package/src/hooks/fp-config.ts +835 -0
  287. package/src/hooks/fp-home.ts +714 -0
  288. package/src/hooks/fp-reset.ts +1363 -0
  289. package/src/hooks/handler.ts +185 -6
  290. package/src/hooks/harness-cli.ts +386 -0
  291. package/src/hooks/hook-activity-store.ts +171 -24
  292. package/src/hooks/hook-logger.ts +2 -2
  293. package/src/hooks/hook-telemetry.ts +5 -1
  294. package/src/hooks/hooks-config.ts +4 -3
  295. package/src/hooks/integrations.ts +0 -1
  296. package/src/hooks/loader-utils.ts +172 -9
  297. package/src/hooks/manager.ts +41 -5
  298. package/src/hooks/migrations.ts +450 -0
  299. package/src/hooks/onboarding-attempt.ts +195 -0
  300. package/src/hooks/onboarding-lock.ts +118 -0
  301. package/src/hooks/policy-types.ts +28 -0
  302. package/src/hooks/session-pause-cli.ts +159 -0
  303. package/src/hooks/session-pause.ts +267 -0
  304. package/src/hooks/setup-state.ts +256 -0
  305. package/src/hooks/tui.ts +209 -9
  306. package/src/hooks/uninstall-cli.ts +418 -0
  307. package/src/hooks/worker-server.ts +134 -15
  308. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  309. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  310. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  311. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0longh-._.js +0 -3
  312. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +0 -3
  313. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +0 -3
  314. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +0 -3
  315. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +0 -3
  316. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +0 -3
  317. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  318. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +0 -4
  319. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +0 -4
  320. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +0 -4
  321. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +0 -4
  322. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0nb0syg._.js +0 -3
  323. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +0 -4
  324. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +0 -4
  325. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0uno39i._.js +0 -3
  326. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +0 -3
  327. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +0 -4
  328. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1psjybo._.js +0 -3
  329. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +0 -4
  330. package/.next/standalone/.next/server/chunks/ssr/_1m_p7pi._.js +0 -3
  331. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_0oy_-36.js +0 -3
  332. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +0 -5
  333. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +0 -5
  334. package/.next/standalone/.next/static/chunks/0h7auy7hzjyhw.js +0 -1
  335. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  336. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  337. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  338. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  339. package/.next/standalone/.next/static/chunks/3i27c3hcriawq.css +0 -1
  340. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  341. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  342. package/.next/standalone/Cargo.lock +0 -428
  343. package/.next/standalone/Cargo.toml +0 -9
  344. package/.next/standalone/crates/PROTOCOL.md +0 -122
  345. package/.next/standalone/crates/failproofaid/Cargo.toml +0 -18
  346. package/.next/standalone/crates/failproofaid/src/lock.rs +0 -81
  347. package/.next/standalone/crates/failproofaid/src/main.rs +0 -83
  348. package/.next/standalone/crates/failproofaid/src/paths.rs +0 -172
  349. package/.next/standalone/crates/failproofaid/src/server.rs +0 -502
  350. package/.next/standalone/crates/failproofaid/src/worker.rs +0 -400
  351. package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +0 -241
  352. package/.next/standalone/crates/fpai-ipc/Cargo.toml +0 -16
  353. package/.next/standalone/crates/fpai-ipc/src/envelope.rs +0 -175
  354. package/.next/standalone/crates/fpai-ipc/src/framing.rs +0 -177
  355. package/.next/standalone/crates/fpai-ipc/src/lib.rs +0 -10
  356. package/.next/standalone/crates/fpai-ipc/src/peer.rs +0 -80
  357. package/.next/standalone/docker-hook-sync/Dockerfile +0 -79
  358. package/.next/standalone/docker-hook-sync/entrypoint.sh +0 -109
  359. package/.next/standalone/integration-suite/Dockerfile +0 -47
  360. package/.next/standalone/integration-suite/README.md +0 -129
  361. package/.next/standalone/integration-suite/canary-policies.mjs +0 -45
  362. package/.next/standalone/integration-suite/capture-tokens.sh +0 -48
  363. package/.next/standalone/integration-suite/ci-entrypoint.sh +0 -179
  364. package/.next/standalone/integration-suite/inject-tokens.sh +0 -44
  365. package/.next/standalone/integration-suite/install-clis.sh +0 -136
  366. package/.next/standalone/integration-suite/probe-cli.sh +0 -248
  367. package/.next/standalone/integration-suite/report.js +0 -120
  368. package/.next/standalone/integration-suite/run.sh +0 -159
  369. package/.next/standalone/openclaw-plugin/index.js +0 -223
  370. package/.next/standalone/openclaw-plugin/openclaw.plugin.json +0 -6
  371. package/.next/standalone/openclaw-plugin/package.json +0 -12
  372. package/.next/standalone/osv-scanner.toml +0 -17
  373. package/.next/standalone/pi-extension/index.ts +0 -524
  374. package/.next/standalone/pi-extension/package.json +0 -12
  375. package/.next/standalone/postcss.config.mjs +0 -8
  376. package/.next/standalone/readme-arch-hq.gif +0 -0
  377. package/.next/standalone/rust-toolchain.toml +0 -4
  378. package/src/auth/cli.ts +0 -371
  379. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_buildManifest.js +0 -0
  380. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_clientMiddlewareManifest.js +0 -0
  381. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_ssgManifest.js +0 -0
@@ -0,0 +1,1363 @@
1
+ /**
2
+ * What happens when this CLI meets a home directory written by a different
3
+ * layout.
4
+ *
5
+ * ## This was wipe-and-re-setup. It is a migration now.
6
+ *
7
+ * The original decision, and the reasoning for it, was: a migration has to be
8
+ * right on every path or it half-moves a home, and a half-moved home fails in the
9
+ * worst available way — the daemon writes where the dashboard does not read, and
10
+ * an absent directory is indistinguishable from an idle one. A reset is one
11
+ * destructive operation that is either done or not done.
12
+ *
13
+ * That was correct while there were no customers, no cloud tokens on real
14
+ * machines, no fleet enrolment and no undelivered event spools. All four now
15
+ * exist, and the cost landed on the other side of the ledger: a wipe deleted the
16
+ * cloud token (so the machine dropped off the fleet, silently, still reporting
17
+ * healthy), `daemon.configured` (so it stopped failing closed), every
18
+ * `extra_paths` a person had typed, and events already read out of transcripts
19
+ * and queued — the last of those PERMANENTLY, because `cursors/` survived and the
20
+ * watermark had already moved past them.
21
+ *
22
+ * So the shape inverted. `HOME_CLASSES` in `fp-home.ts` classifies every path by
23
+ * what it HOLDS and the delete list is derived from that, which means the default
24
+ * is now "carried" and deletion is the exception a class has to earn. The
25
+ * half-moved-home worry is answered by keeping the property that made a reset
26
+ * safe: `VERSION` is stamped only after a step completes, so a step that fails
27
+ * leaves the home marked with the OLD layout and the next command retries it. No
28
+ * home is ever marked current on the strength of a partial migration.
29
+ *
30
+ * `migrations.ts` owns the ORDER and the record — which steps exist, which ran,
31
+ * and what was copied aside first. This file owns the MOVES.
32
+ *
33
+ * ## Where the deletion happens, and where it deliberately does not
34
+ *
35
+ * Only a real CLI command resets. A HOOK never deletes anything, because a
36
+ * hook runs unattended, once per tool call, with an agent waiting on stdout —
37
+ * removing a user's audit history and cursors from inside one would be a
38
+ * destructive act nobody asked for and nobody saw.
39
+ *
40
+ * But a hook must not stay silent either. On a stale layout the merged config
41
+ * resolves to nothing, so every builtin policy quietly stops firing: the
42
+ * machine looks protected and is not. That is the single worst outcome here,
43
+ * so the hook path says so on stderr, every time, until setup is re-run.
44
+ *
45
+ * ## Why the hook does not simply deny
46
+ *
47
+ * Failing closed is this product's instinct, and it is the wrong instinct
48
+ * here. A stale layout is the result of an upgrade the user did not ask to be
49
+ * interrupted by, and this branch has already demonstrated — on this very
50
+ * machine — that a blanket deny takes `UserPromptSubmit` with it and locks the
51
+ * user out of their agent entirely, with no way back except hand-editing JSON.
52
+ * A loud warning that survives until setup runs is the proportionate answer.
53
+ */
54
+ import {
55
+ copyFileSync,
56
+ existsSync,
57
+ mkdirSync,
58
+ readFileSync,
59
+ readdirSync,
60
+ renameSync,
61
+ rmSync,
62
+ rmdirSync,
63
+ statSync,
64
+ writeFileSync,
65
+ } from "node:fs";
66
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
67
+ import { rebuildHookActivityStats } from "./hook-activity-store";
68
+ import {
69
+ LAYOUT_VERSION,
70
+ customPoliciesDir,
71
+ failproofaiHome,
72
+ globalPolicyConfigFile,
73
+ hookActivityDir,
74
+ legacy,
75
+ migrationBackupDir,
76
+ policiesDir,
77
+ spoolDir,
78
+ failedDir,
79
+ resettablePaths,
80
+ } from "./fp-home";
81
+ import {
82
+ detectLayout,
83
+ projectConfig,
84
+ projectCredentials,
85
+ readConfig,
86
+ updateConfig,
87
+ writeConfig,
88
+ writeCredentials,
89
+ writeVersionFile,
90
+ type FpConfig,
91
+ type FpCredentials,
92
+ type LayoutState,
93
+ } from "./fp-config";
94
+ import {
95
+ daemonServiceStatus,
96
+ daemonStatusCommand,
97
+ daemonVersionSkew,
98
+ isDaemonSupportedPlatform,
99
+ probeDaemonEndToEnd,
100
+ } from "./daemon-service";
101
+
102
+ export interface ResetOutcome {
103
+ /** Paths that existed and were removed. */
104
+ removed: string[];
105
+ /** Basenames of user-authored policy files moved into `custom-policies/`. */
106
+ migrated: string[];
107
+ /** Basenames of decision-log pages carried into layout 2's `hook-activity/`. */
108
+ activity: string[];
109
+ /** Keys of the layout-1 policy config carried into layout 2's. */
110
+ policyConfig: string[];
111
+ /**
112
+ * Undelivered event batches moved out of a legacy root `spool/`/`failed/`.
113
+ *
114
+ * Reported rather than discarded because these are events that had NOT been
115
+ * shipped: if the number is ever non-zero on a real machine it is the only
116
+ * evidence that a root spool existed at all, and silence would make a carry
117
+ * indistinguishable from a directory that was never there.
118
+ */
119
+ spooled: string[];
120
+ /** The layout that was found before the reset. */
121
+ from: number;
122
+ }
123
+
124
+ /** Script files a person could have written by hand. `.d.ts` is a type stub. */
125
+ /**
126
+ * Move the user's own policy directory back up: layout 2's
127
+ * `policies/custom-policies/` into layout 3's `policies/`, where the loader
128
+ * reads it.
129
+ *
130
+ * Left alone, nothing would ever load those files again — and worse, the reset
131
+ * DELETES `custom-policies/` (it is in `resettablePaths()`), so they would not
132
+ * merely stop loading, they would be gone.
133
+ *
134
+ * EVERY entry moves — every file regardless of extension, and every
135
+ * subdirectory — not just the loadable sources. Moving only `*.{js,mjs,ts}`
136
+ * deleted the rest along with the directory, and the two things it deleted are
137
+ * exactly the things a real policy depends on: a `lib/` of shared helpers the
138
+ * policy imports, and the `.json` data file it reads its rules from. The
139
+ * surviving policy then referenced a `./lib/rules.mjs` that no longer existed,
140
+ * so the migration turned a working policy into a broken one AND destroyed
141
+ * source nothing regenerates. Reproduced exactly that way on a seeded home.
142
+ *
143
+ * Moving the whole directory is also what keeps relative imports valid: every
144
+ * entry keeps its position relative to every other, so `./lib/rules.mjs`
145
+ * resolves after the move for the same reason it did before.
146
+ *
147
+ * A destination that already exists is never overwritten. Two things make that
148
+ * safe rather than lossy, and BOTH are required:
149
+ *
150
+ * - DIRECTORIES MERGE. A colliding directory is not a conflict — `lib/` on both
151
+ * sides usually holds different files, and the likeliest collision of all is
152
+ * exactly `lib/`, because layout 1 → 2 left one behind (it moved only
153
+ * `*.{js,mjs,ts}`, which is the bug this function now fixes). Skipping the
154
+ * whole directory on a name match discarded every file inside it. Only a
155
+ * genuine leaf collision — the same name, and at least one side a file — is
156
+ * left alone.
157
+ * - WHAT CANNOT MOVE IS NOT DELETED. `custom-policies/` is deliberately NOT in
158
+ * `resettablePaths()`; this function removes it itself, and only once it is
159
+ * empty. It used to be on that list, so every entry deliberately "left in
160
+ * place" was deleted seconds later by the same `resetHome()` call that had
161
+ * just decided to preserve it — the user lost hand-written source, and the
162
+ * policy that DID move was left importing a `./lib/rules.mjs` that no longer
163
+ * existed. Verified on a seeded home: the helper was gone from the machine
164
+ * entirely and the surviving policy failed to load.
165
+ */
166
+ export function migrateConventionPolicies(): string[] {
167
+ // LAYOUT 3 REVERSED THIS. Layout 2 moved the user's `*.mjs` DOWN into
168
+ // `policies/custom-policies/`; layout 3 reads them straight out of
169
+ // `policies/`, which is where layout 1 kept them all along. So:
170
+ //
171
+ // • from layout 1 — nothing to do. The files are already in the directory
172
+ // that now loads them, and moving them anywhere would be a regression.
173
+ // • from layout 2 — move them back UP, out of `custom-policies/`, or the
174
+ // reset deletes that directory (it is in `resettablePaths()`) and takes
175
+ // the user's own policies with it.
176
+ //
177
+ // `resetHome` calls this BEFORE it deletes anything, which is what makes the
178
+ // second case safe rather than a race.
179
+ const from = legacy.customPoliciesDir();
180
+ if (!existsSync(from)) return [];
181
+ const moved: string[] = [];
182
+
183
+ // Returns the names it moved, relative to `dir`. Recurses only where both
184
+ // sides are directories; everything else either moves whole or stays put.
185
+ const mergeInto = (dir: string, dest: string, prefix: string): void => {
186
+ let entries;
187
+ try {
188
+ // No filter: this whole directory is the user's — policy sources, the
189
+ // helpers they import, and the data files they read.
190
+ entries = readdirSync(dir, { withFileTypes: true });
191
+ } catch {
192
+ // A directory we cannot read is not worth aborting a reset over, and its
193
+ // contents stay where they are rather than being lost.
194
+ return;
195
+ }
196
+ if (entries.length === 0) return;
197
+ try {
198
+ mkdirSync(dest, { recursive: true });
199
+ } catch {
200
+ return;
201
+ }
202
+ for (const entry of entries) {
203
+ const source = resolve(dir, entry.name);
204
+ const target = resolve(dest, entry.name);
205
+ const name = `${prefix}${entry.name}`;
206
+ if (!existsSync(target)) {
207
+ try {
208
+ renameSync(source, target);
209
+ moved.push(name);
210
+ } catch {
211
+ // One unmovable entry must not strand the rest — and because this
212
+ // directory is no longer on the reset list, "not moved" now really
213
+ // does mean "still on disk".
214
+ }
215
+ continue;
216
+ }
217
+ // Both directories: merge their contents rather than discarding the
218
+ // source wholesale. Anything genuinely colliding inside is left alone by
219
+ // the same rule, one level down.
220
+ if (entry.isDirectory() && statSync(target).isDirectory()) {
221
+ mergeInto(source, target, `${name}/`);
222
+ // AND remove the child once the recursion drained it. Without this, a
223
+ // merged directory leaves an empty husk behind, so the `rmdirSync(from)`
224
+ // below throws ENOTEMPTY into a swallowing `catch` and
225
+ // `custom-policies/` survives the migration it just completed —
226
+ // permanently, because the next run recurses into the same empty child
227
+ // and fails the same way, so it never self-heals.
228
+ //
229
+ // Only reachable when the merge moved EVERYTHING: a genuine leaf
230
+ // collision inside leaves a file here, `rmdirSync` refuses, and the
231
+ // catch is then correct — that husk is the user's remaining source,
232
+ // which is the one thing this function must not delete.
233
+ try {
234
+ rmdirSync(source);
235
+ } catch {
236
+ // Not empty, so something was deliberately left. Keep it.
237
+ }
238
+ }
239
+ }
240
+ };
241
+
242
+ mergeInto(from, policiesDir(), "");
243
+
244
+ // Remove the directory ONLY if the merge emptied it. A non-empty
245
+ // `custom-policies/` is the user's remaining files, and this is the one place
246
+ // that decides their fate — `resettablePaths()` no longer lists it.
247
+ try {
248
+ rmdirSync(from);
249
+ } catch {
250
+ // Not empty, or not removable. Either way it stays, which is the point.
251
+ }
252
+ return moved.sort((a, b) => a.localeCompare(b));
253
+ }
254
+
255
+ /**
256
+ * Carry layout 1's decision log into layout 2, WITHOUT re-shipping it.
257
+ *
258
+ * `cache/hook-activity` was deleted along with the rest of `cache/`, so an
259
+ * upgrade silently discarded every decision the machine had ever recorded —
260
+ * the data the dashboard's activity tab exists to show, and on a connected
261
+ * machine the data an operator had already been billed the collection of.
262
+ *
263
+ * # Why this MOVES rather than copies
264
+ *
265
+ * The collector keys its cursors on `(device, inode)` — deliberately, because
266
+ * the store rotates by RENAMING `current.jsonl`, and a path-keyed cursor would
267
+ * both re-ship the rotated file and carry its offset onto the fresh one.
268
+ * `rename()` preserves the inode, so a moved page is still the file its cursor
269
+ * belongs to and resumes at the right offset. A copy gives every page a NEW
270
+ * inode, which reads as "never seen before" and re-ships all of it.
271
+ *
272
+ * `head_fingerprint` is what makes that safe rather than lucky: the cursor
273
+ * verifies the file's first bytes whenever a resumed cursor's path has changed,
274
+ * which is exactly this situation. It was added for inode REUSE; a migration is
275
+ * the same question asked the other way round.
276
+ *
277
+ * The fallback for `EXDEV` (a `cache/` on a different filesystem — rare, since
278
+ * both live under one home, but possible with bind mounts) is a copy, accepting
279
+ * that those pages re-ship. Ingest dedups on a content hash and collapses them
280
+ * at merge, so the cost is bandwidth, not duplicate rows.
281
+ *
282
+ * # What is deliberately NOT carried
283
+ *
284
+ * `current.count` and `stats.json` are derived state, and two of each cannot be
285
+ * merged without inventing a number, so they are dropped — and then REBUILT here,
286
+ * explicitly, by `rebuildHookActivityStats()`.
287
+ *
288
+ * That call is the fix for a real loss. This comment used to say the store rebuilt
289
+ * them by itself; it did not. `stats.json` is incremental — one entry folded in per
290
+ * append, nothing ever rescans — so a dropped file simply read as zeroes and began
291
+ * accumulating again from the next event. A user upgrading from a pre-daemon home
292
+ * kept every record and lost every total: the dashboard listed their history while
293
+ * reporting 0 events, 0 denies and no top policy. Verified on a seeded home before
294
+ * and after. Dropping it is still right — the numbers are exactly recomputable
295
+ * because pages are never pruned — but only if something actually recomputes them. The legacy `current.jsonl` is moved under a PAGE name rather than onto
296
+ * the destination's own `current.jsonl`, which may already exist and may be
297
+ * mid-write — a rotated page is exactly what the store would have made of it.
298
+ */
299
+ /**
300
+ * Move layout 1's root `spool/` and `failed/` into the daemon's `state/` pair.
301
+ *
302
+ * Both root paths are on the retired list, so the migration DELETES them, and
303
+ * nothing regenerates an undelivered event: once the file is gone the decision it
304
+ * records was never reported and never will be. `HOME_CLASSES` classes the layout-3
305
+ * equivalents `undelivered` with the note "never deleted" — so without this the two
306
+ * halves of the same module contradicted each other and the delete won.
307
+ *
308
+ * It is insurance rather than a live path, and that is worth being precise about:
309
+ * no PUBLISHED version writes a root spool. `fpai-collect` used `home.join("spool")`
310
+ * only on the unmerged daemon branch; the commit that reached `main` already wrote
311
+ * `state/spool`, and the pre-daemon line (0.0.x) has no spool concept at all —
312
+ * checked against the published 0.0.10, 0.0.14, 0.0.15 and 1.0.0-beta.0 tarballs.
313
+ * So on every real machine this finds nothing and costs a single `existsSync`.
314
+ *
315
+ * It exists because "listed for deletion, with no carry and no backup" is a trap
316
+ * regardless of whether anything currently falls into it: the next thing to write a
317
+ * root spool would lose undelivered telemetry silently, and the cost of closing it
318
+ * now is one directory walk.
319
+ *
320
+ * Carried INTO the live spool rather than into the backup, so the events actually
321
+ * ship — `drainSpoolAfterMigrating()` flushes `state/spool` moments later. Safe for
322
+ * an unknown-format file because the uploader quarantines a batch it cannot send
323
+ * into `failed/` rather than failing on it, so the worst case is a preserved file
324
+ * in the place designed to hold preserved files.
325
+ */
326
+ function migrateLegacySpool(): string[] {
327
+ const moved: string[] = [];
328
+ for (const [from, to] of [
329
+ [legacy.spoolDir(), spoolDir()],
330
+ [legacy.failedDir(), failedDir()],
331
+ ] as const) {
332
+ if (!existsSync(from)) continue;
333
+ try {
334
+ mkdirSync(to, { recursive: true });
335
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
336
+ if (!entry.isFile()) continue;
337
+ // Never overwrite: a same-named batch in the destination is a different
338
+ // batch with different events, and losing either defeats the point.
339
+ let name = entry.name;
340
+ let n = 0;
341
+ while (existsSync(resolve(to, name))) name = `legacy-${n++}-${entry.name}`;
342
+ try {
343
+ renameSync(resolve(from, entry.name), resolve(to, name));
344
+ moved.push(name);
345
+ } catch {
346
+ try {
347
+ copyFileSync(resolve(from, entry.name), resolve(to, name));
348
+ moved.push(name);
349
+ } catch {
350
+ // Unreadable. Left where it is; the delete below may remove it, which
351
+ // is the pre-existing behaviour rather than a regression.
352
+ }
353
+ }
354
+ }
355
+ } catch {
356
+ // A spool directory we cannot read is not worth aborting a reset over.
357
+ }
358
+ }
359
+ return moved;
360
+ }
361
+
362
+ export function migrateHookActivity(): string[] {
363
+ const from = legacy.hookActivityDir();
364
+ if (!existsSync(from)) return [];
365
+ const moved: string[] = [];
366
+ try {
367
+ const entries = readdirSync(from, { withFileTypes: true }).filter(
368
+ (e) => e.isFile() && e.name.endsWith(".jsonl"),
369
+ );
370
+ if (entries.length === 0) return [];
371
+
372
+ const to = hookActivityDir();
373
+ mkdirSync(to, { recursive: true });
374
+
375
+ // One timestamp for the whole migration, with a per-file counter, so the
376
+ // names are stable, ordered, and cannot collide with each other.
377
+ const stamp = Date.now();
378
+ let seq = 0;
379
+
380
+ for (const entry of entries) {
381
+ const source = resolve(from, entry.name);
382
+ // `current.jsonl` becomes a page: the destination has its own, and the
383
+ // store's reader treats pages and current identically.
384
+ let name = entry.name === "current.jsonl" ? `page-${stamp}-${seq++}.jsonl` : entry.name;
385
+ let target = resolve(to, name);
386
+ // Never overwrite. A same-named page in the destination is a different
387
+ // file with different records, and losing either is worse than a rename.
388
+ while (existsSync(target)) {
389
+ name = `page-${stamp}-${seq++}.jsonl`;
390
+ target = resolve(to, name);
391
+ }
392
+ try {
393
+ renameSync(source, target);
394
+ moved.push(name);
395
+ } catch {
396
+ // Fall back to a copy on ANY rename failure, not just EXDEV.
397
+ //
398
+ // EXDEV (a rename across filesystems) was the only code handled, on the
399
+ // reasoning that it is the only one a copy can rescue. It is not: a
400
+ // rename needs write permission on the SOURCE DIRECTORY, while a copy
401
+ // needs only read on the file and write on the destination — so EACCES,
402
+ // EPERM and EROFS on `cache/` all fail the rename and all succeed as a
403
+ // copy. Those were silently dropped from the carry with no attempt made.
404
+ //
405
+ // Getting this wrong is permanent rather than deferred. The comment here
406
+ // used to say a page left behind is merely "still there", which is true
407
+ // of the file and false of its fate: `resetHome` stamps VERSION at the
408
+ // end regardless, `detectLayout` then reports `current`, and this
409
+ // function never runs again — so the page is not left for a retry, it is
410
+ // abandoned in the old layout where nothing reads it.
411
+ //
412
+ // The copy leaves the original in place, which is the right trade in the
413
+ // one direction that matters: the store's reader is keyed on the pages it
414
+ // finds under the CURRENT layout, so a duplicate there would double-count
415
+ // and a duplicate left behind is inert.
416
+ try {
417
+ copyFileSync(source, target);
418
+ moved.push(name);
419
+ } catch {
420
+ // Genuinely unreadable. Reported by omission from `activity`, which is
421
+ // the signal `resetHome`'s caller prints.
422
+ }
423
+ }
424
+ }
425
+ } catch {
426
+ // An activity directory we cannot read is not worth aborting a reset over.
427
+ }
428
+ // AFTER the pages are in place, so the rebuild sees the carried history rather
429
+ // than only whatever the new layout already had. Best-effort: a machine whose
430
+ // totals cannot be rewritten still keeps every record, which is the half that
431
+ // matters, and the next append starts accumulating from whatever it managed.
432
+ if (moved.length > 0) {
433
+ try {
434
+ rebuildHookActivityStats();
435
+ } catch {
436
+ // Never fail a migration over a derived number.
437
+ }
438
+ }
439
+ return moved.sort((a, b) => a.localeCompare(b));
440
+ }
441
+
442
+ /**
443
+ * The eight keys the layout-2 carry used to move, kept only as a record.
444
+ *
445
+ * It was an ALLOWLIST, and that was the bug: anything outside these eight names
446
+ * was dropped, including a key a NEWER build had written into a layout-2 file.
447
+ * The carry preserves every key now and deletes only the retired ones
448
+ * (`RETIRED_POLICY_CONFIG_KEYS`), which is the same rule `writeConfig` follows for
449
+ * `config.json` — unowned keys survive, dead ones go.
450
+ *
451
+ * The one exclusion this list existed for is still enforced, by that other
452
+ * constant: layout 1's file also carried a `collector` block, and layout 2 moved
453
+ * collector settings to `config.toml` in snake_case — deliberately, because
454
+ * `fpai-collect`'s `Settings` deserializes them and camelCase keys would make
455
+ * every field silently fall back to its default (see the note on `Settings` in
456
+ * `crates/fpai-collect/src/config.rs`, which records that exact bug). Carrying
457
+ * `collector` forward would put a block into the new file that nothing reads,
458
+ * looking like a preserved setting and behaving like an absent one.
459
+ */
460
+
461
+ /**
462
+ * Carry the user's policy SELECTION across the layout-1 → layout-2 move.
463
+ *
464
+ * Layout 1 kept it at `~/.failproofai/policies-config.json`; layout 2 keeps it
465
+ * at `policies/local-policies/policies-config.json`, and both were on the reset
466
+ * list. So an upgrade silently emptied `enabledPolicies` — every builtin the
467
+ * user had turned on, every explicit `customPoliciesPaths` entry, and every
468
+ * per-policy parameter. The machine still read as configured afterwards
469
+ * (`isConfigured()` is a union that sees the agent CLIs' untouched settings
470
+ * files), so hooks kept firing against a policy set that had quietly become
471
+ * the default one. That is the same silent enforcement gap
472
+ * `migrateConventionPolicies()` exists to close, by a different route.
473
+ *
474
+ * This is deliberately NARROW. The standing decision for layout 1 is
475
+ * wipe-and-re-setup rather than migrate, because a half-migrated home that
476
+ * reads "no data" is worse than one that says so — see `legacy` in
477
+ * `fp-home.ts`. Everything derived (cursors, spool, health, audit cache) still
478
+ * goes and is rebuilt. What is carried is only what a person typed and nothing
479
+ * regenerates, which is the same test `migrateHookActivity()` applies to the
480
+ * decision log.
481
+ *
482
+ * # Two phases, because the source and the destination are BOTH on the list
483
+ *
484
+ * Unlike the two migrations above, this one cannot run entirely before the
485
+ * deletions. Its source (`legacy.policyConfig()`) is removed by them, and so is
486
+ * its destination (`globalPolicyConfigFile()`) — rightly, since clearing a
487
+ * stale selection on a layout migration is the documented behaviour. Writing
488
+ * first would have the reset delete the carry moments after it happened, which
489
+ * is exactly what the note on `hookActivityDir()` in `resettablePaths()` records
490
+ * happening once already.
491
+ *
492
+ * So: READ before, WRITE after.
493
+ */
494
+ /**
495
+ * Keys the policy config used to hold that nothing reads any more.
496
+ *
497
+ * `collector` is the whole list, and it is why the layout-1 carry was an
498
+ * ALLOWLIST rather than a copy: layout 1 kept collector settings here in
499
+ * camelCase, and layout 2 moved them to `config.toml`/`config.json` in
500
+ * snake_case, where `fpai-collect`'s `Settings` deserializes them. A camelCase
501
+ * `collector` block sitting in the layout-3 file reads as a preserved setting and
502
+ * does nothing — the worst of both, because it looks answered.
503
+ *
504
+ * A NAMED list, not "everything outside the keep-list". The file survives the
505
+ * reset now (`HOME_CLASSES` classes it `user-typed`), so dropping by exclusion
506
+ * would delete every key a NEWER build had written — which is the bug Phase 1
507
+ * fixed for `config.json`, arriving here by a different door.
508
+ */
509
+ const RETIRED_POLICY_CONFIG_KEYS = ["collector"] as const;
510
+
511
+ /**
512
+ * Remove the retired keys from the policy config, in place.
513
+ *
514
+ * Runs regardless of which layout we came from: a key is retired or it is not,
515
+ * and a layout-2 home carrying a stray layout-1 root file should be cleaned the
516
+ * same way. Returns what it removed.
517
+ *
518
+ * The file is DELETED if stripping empties it — a `{}` policy config is not a
519
+ * selection, and the old behaviour for a file holding nothing but `collector`
520
+ * was no file at all.
521
+ */
522
+ export function retirePolicyConfigKeys(): string[] {
523
+ const at = globalPolicyConfigFile();
524
+ let parsed: Record<string, unknown>;
525
+ try {
526
+ const raw = JSON.parse(readFileSync(at, "utf8")) as unknown;
527
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
528
+ parsed = raw as Record<string, unknown>;
529
+ } catch {
530
+ // Absent or unparseable. Not worth aborting a reset over, and there is
531
+ // nothing to retire — the same answer the carry gives for the same input.
532
+ return [];
533
+ }
534
+ const removed = RETIRED_POLICY_CONFIG_KEYS.filter((k) => parsed[k] !== undefined);
535
+ if (removed.length === 0) return [];
536
+ for (const key of removed) delete parsed[key];
537
+ try {
538
+ if (Object.keys(parsed).length === 0) rmSync(at, { force: true });
539
+ else writeFileSync(at, JSON.stringify(parsed, null, 2) + "\n", "utf8");
540
+ } catch {
541
+ // A file we cannot rewrite keeps its dead key. Misleading, but not fatal,
542
+ // and not worth failing the migration over.
543
+ return [];
544
+ }
545
+ return [...removed];
546
+ }
547
+
548
+ export function readCarriedPolicyConfig(): Record<string, unknown> | null {
549
+ // LAYOUT 2's NESTED COPY ONLY — `policies/local-policies/policies-config.json`.
550
+ //
551
+ // This used to prefer that path and fall back to `legacy.policyConfig()`, the
552
+ // layout-1 file at the home root. That fallback is gone because the file is:
553
+ // layout 3 puts the live config back at exactly that path, `HOME_CLASSES`
554
+ // classes it `user-typed`, and it is no longer deleted — so a layout-1 home
555
+ // keeps it untouched, with EVERY key rather than the eight this function knows
556
+ // to carry. Reading it here and writing it back would be the only thing that
557
+ // could still narrow it.
558
+ //
559
+ // Layout 2's copy is different: `legacy.localPoliciesDir()` is on the retired
560
+ // list and really is deleted, so without this it is lost. A home cannot hold
561
+ // both — layout 2 never wrote the root file, and going 1 → 2 removed it.
562
+ const from = resolve(legacy.localPoliciesDir(), "policies-config.json");
563
+ if (!existsSync(from)) return null;
564
+
565
+ let parsed: Record<string, unknown>;
566
+ try {
567
+ parsed = JSON.parse(readFileSync(from, "utf8")) as Record<string, unknown>;
568
+ } catch {
569
+ // Unparseable is not worth aborting a reset over, and there is nothing to
570
+ // carry. The file is removed with the rest of layout 1, which is also what
571
+ // would have happened before this function existed.
572
+ return null;
573
+ }
574
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
575
+
576
+ // EVERY key except the retired ones. This was an eight-name ALLOWLIST, and it
577
+ // dropped anything outside it — including a key a NEWER build had written into a
578
+ // layout-2 file, which is the same loss Phase 1 fixed for `config.json` arriving
579
+ // by a third door. Caught by a smoke test on a seeded home: a `futureKey` in the
580
+ // nested config was gone after the migration, silently.
581
+ //
582
+ // Retired keys still go, because they are dead rather than unknown — see
583
+ // `RETIRED_POLICY_CONFIG_KEYS` for why leaving `collector` here is worse than
584
+ // deleting it.
585
+ const carried: Record<string, unknown> = {};
586
+ for (const [key, value] of Object.entries(parsed)) {
587
+ if ((RETIRED_POLICY_CONFIG_KEYS as readonly string[]).includes(key)) continue;
588
+ carried[key] = value;
589
+ }
590
+ // Nothing worth carrying — a file holding only retired fields must not produce
591
+ // an empty config that looks like a real one.
592
+ if (Object.keys(carried).length === 0) return null;
593
+
594
+ // `enabledPolicies` is required by the type and by every reader. A layout-1
595
+ // file that somehow lacked it would otherwise produce a layout-2 file that
596
+ // throws on read — worse than the empty default it replaces.
597
+ if (carried.enabledPolicies === undefined) carried.enabledPolicies = [];
598
+ rewriteCarriedCustomPaths(carried);
599
+ return carried;
600
+ }
601
+
602
+ /**
603
+ * Repoint `customPoliciesPaths` at where the files now live, in place.
604
+ *
605
+ * `migrateConventionPolicies()` moves layout 2's `policies/custom-policies/*` up
606
+ * into `policies/`, and nothing rewrote the paths the user had REGISTERED — so
607
+ * every explicit entry still named the directory the migration had just deleted.
608
+ * Reproduced on a seeded layout-2 home: the file was correctly at
609
+ * `policies/acme.mjs`, and the config still said
610
+ * `policies/custom-policies/acme.mjs`, which resolved to nothing.
611
+ *
612
+ * Layout 3 collapses `customPoliciesDir()` onto `policiesDir()`, so a moved file
613
+ * is still discovered BY CONVENTION and usually keeps firing — which is exactly
614
+ * what made this quiet. It is not harmless: a convention-loaded policy gets a
615
+ * different id from an explicitly-pathed one, and `disabledCustomPolicies` records
616
+ * a disable against that id, so a policy the user had switched off can come back.
617
+ * Repointing the path keeps the id it had.
618
+ *
619
+ * Layout-2 shaped on purpose. Layout 1 kept these files in `policies/` already —
620
+ * the same place layout 3 does — so a layout-1 path needs no rewrite, and its
621
+ * config is not carried through here anyway.
622
+ */
623
+ function rewriteCarriedCustomPaths(carried: Record<string, unknown>): void {
624
+ const fromDir = legacy.customPoliciesDir();
625
+ const toDir = policiesDir();
626
+ const repoint = (value: unknown): unknown => {
627
+ if (typeof value !== "string" || value === "") return value;
628
+ const abs = resolve(value);
629
+ // `relative()` rather than a prefix test on the string: `policies/custom-policies-old`
630
+ // starts with `policies/custom-policies` and is a DIFFERENT directory the
631
+ // migration never touched, so a prefix match would move a path that is still
632
+ // correct. An entry outside the moved tree is returned untouched.
633
+ const rel = relative(fromDir, abs);
634
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return value;
635
+ return resolve(toDir, rel);
636
+ };
637
+
638
+ if (Array.isArray(carried.customPoliciesPaths)) {
639
+ carried.customPoliciesPaths = carried.customPoliciesPaths.map(repoint);
640
+ }
641
+ // The legacy singular field, still accepted on read.
642
+ if (typeof carried.customPoliciesPath === "string") {
643
+ carried.customPoliciesPath = repoint(carried.customPoliciesPath);
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Second phase of {@link readCarriedPolicyConfig}; runs AFTER the deletions.
649
+ *
650
+ * MERGES onto whatever is at the destination rather than replacing it. The
651
+ * destination is `user-typed` now and survives the reset, so a plain write would
652
+ * truncate a live file down to the keys this carry happens to know about — the
653
+ * same class of loss the carry exists to prevent, arriving by the other door.
654
+ * The carried values still win: they are the ones being migrated.
655
+ */
656
+ export function writeCarriedPolicyConfig(carried: Record<string, unknown> | null): string[] {
657
+ if (!carried) return [];
658
+ const to = globalPolicyConfigFile();
659
+ let existing: Record<string, unknown> = {};
660
+ try {
661
+ const parsed = JSON.parse(readFileSync(to, "utf8")) as unknown;
662
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
663
+ existing = parsed as Record<string, unknown>;
664
+ }
665
+ } catch {
666
+ // Absent or unparseable — the carry is the whole file, as before.
667
+ }
668
+ try {
669
+ mkdirSync(dirname(to), { recursive: true });
670
+ writeFileSync(to, JSON.stringify({ ...existing, ...carried }, null, 2) + "\n", "utf8");
671
+ } catch {
672
+ // A destination we cannot write is not worth aborting the reset over; the
673
+ // user re-runs setup, which is the pre-existing behaviour.
674
+ return [];
675
+ }
676
+ return Object.keys(carried).sort((a, b) => a.localeCompare(b));
677
+ }
678
+
679
+ /**
680
+ * Delete every path layout 1 or layout 2 could have written, then stamp
681
+ * VERSION so the next run reads as current.
682
+ *
683
+ * Enumerated rather than "remove the home directory": a reset must never take
684
+ * out something a future layout adds that this list has not been taught about,
685
+ * and `bin/` and `run/` are excluded on purpose — a downloaded daemon binary is
686
+ * large, version-pinned and re-verified on use, and `run/` holds sockets
687
+ * belonging to a process that may be alive right now.
688
+ */
689
+ /**
690
+ * Carry a telemetry OPT-OUT across a layout-2 upgrade.
691
+ *
692
+ * Layout 2 kept it in `config.toml`, which the reset deletes, and layout 3
693
+ * writes a fresh `config.json` whose default is `telemetry.enabled: true`. So an
694
+ * upgrade silently turned anonymous telemetry back ON for anyone who had turned
695
+ * it off — and that file is the ONLY off-switch that reaches the daemon, because
696
+ * a system-scope service unit does not inherit `FAILPROOFAI_TELEMETRY_DISABLED`
697
+ * from anyone's shell. An opt-out that a routine upgrade revokes is not an
698
+ * opt-out.
699
+ *
700
+ * Deliberately one-directional: only `false` is carried. Everything else in that
701
+ * file is either re-derived by setup or a thing the wizard re-asks, and carrying
702
+ * an ENABLED flag forward would be carrying the default, which is not a choice
703
+ * anyone made. This matches the narrow rule the policy-config carry follows —
704
+ * move only what a person typed and nothing regenerates.
705
+ *
706
+ * A regex rather than a TOML parser, for the reason `readLegacyTomlVersion`
707
+ * gives: the dependency is gone, and this is a flat `key = value` file this
708
+ * codebase wrote. Read BEFORE the deletions, applied after — same two-phase
709
+ * shape, same reason.
710
+ */
711
+ export function readCarriedTelemetryOptOut(): boolean {
712
+ const from = legacy.configToml();
713
+ if (!existsSync(from)) return false;
714
+ try {
715
+ const text = readFileSync(from, "utf8");
716
+ // Scoped to the `[telemetry]` table, not the whole file — a stray
717
+ // `enabled = false` under `[collector]` is not a telemetry opt-out.
718
+ //
719
+ // Sliced rather than matched with a single expression: the obvious form ends
720
+ // the section with `(?=^\s*\[|\Z)`, and JavaScript HAS NO `\Z`. It parses as
721
+ // a literal "Z", so the lookahead only ever succeeded via its other branch —
722
+ // meaning the section was found when another table followed it and MISSED
723
+ // whenever `[telemetry]` was last in the file, which is exactly where a
724
+ // hand-added opt-out tends to be. Caught by the test for this function; the
725
+ // live fixture happened to have `[collector]` after it and passed.
726
+ const start = text.search(/^\s*\[telemetry\]\s*$/m);
727
+ if (start === -1) return false;
728
+ const body = text.slice(start).replace(/^\s*\[telemetry\]\s*$/m, "");
729
+ const nextTable = body.search(/^\s*\[/m);
730
+ const section = nextTable === -1 ? body : body.slice(0, nextTable);
731
+ return /^\s*enabled\s*=\s*false\s*$/m.test(section);
732
+ } catch {
733
+ return false;
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Parse the TOML subset layout 2 actually wrote, into the object shape layout 3
739
+ * parses out of JSON.
740
+ *
741
+ * NOT a TOML implementation, and it does not need to be: layout 2's `config.toml`
742
+ * and `credentials.toml` were written by this codebase, by two functions that
743
+ * emitted nothing but `[table]` / `[dotted.table]` headers and `key = <value>`
744
+ * lines where every value went through `JSON.stringify`. So `JSON.parse` on the
745
+ * right-hand side is EXACT rather than approximate — strings, booleans, numbers
746
+ * and the one array (`extra_paths`) all round-trip by construction. Removing the
747
+ * `toml` dependency was half the point of layout 3; adding it back to read two
748
+ * files we wrote ourselves would undo that.
749
+ *
750
+ * The output uses layout 2's own snake_case key names, unchanged, because layout
751
+ * 3's JSON uses exactly the same ones (`hooks_verbosity`, `machine_id`,
752
+ * `interval_days`, `sources.<h>.extra_paths`). That is what lets the carry run
753
+ * `projectConfig` / `projectCredentials` — the SAME projections the JSON readers
754
+ * use — instead of a second reader that would have to be kept in step. The only
755
+ * thing that differs between the two layouts is how bytes become an object.
756
+ *
757
+ * A dotted header nests: `[collector.sources.claude]` lands at
758
+ * `collector.sources.claude`, which is where `readSources` looks for it.
759
+ * Anything unparseable is skipped rather than throwing, because a single
760
+ * malformed line must not cost the user the whole file.
761
+ */
762
+ export function parseLegacyToml(text: string): Record<string, unknown> {
763
+ const root: Record<string, unknown> = {};
764
+ let table = root;
765
+ for (const raw of text.split("\n")) {
766
+ const line = raw.trim();
767
+ if (line === "" || line.startsWith("#")) continue;
768
+ const header = /^\[([^\]]+)\]$/.exec(line);
769
+ if (header) {
770
+ table = root;
771
+ for (const part of header[1].split(".")) {
772
+ const key = part.trim();
773
+ if (!key) break;
774
+ const existing = table[key];
775
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
776
+ table = existing as Record<string, unknown>;
777
+ } else {
778
+ const created: Record<string, unknown> = {};
779
+ table[key] = created;
780
+ table = created;
781
+ }
782
+ }
783
+ continue;
784
+ }
785
+ const eq = line.indexOf("=");
786
+ if (eq === -1) continue;
787
+ const key = line.slice(0, eq).trim();
788
+ if (!key) continue;
789
+ try {
790
+ table[key] = JSON.parse(line.slice(eq + 1).trim()) as unknown;
791
+ } catch {
792
+ // A value this codebase did not write, or a hand-edit that broke it. Skip
793
+ // the line; the projection then falls back to that field's default, which
794
+ // is the same answer an absent key gets.
795
+ }
796
+ }
797
+ return root;
798
+ }
799
+
800
+ /**
801
+ * Carry the whole layout-2 `config.toml` across, not just the telemetry opt-out.
802
+ *
803
+ * `readCarriedTelemetryOptOut()` above rescued one field, and the reason it was
804
+ * only one was that the rest was described as "re-derived by setup or a thing the
805
+ * wizard re-asks". That is true of a machine whose owner is about to re-run
806
+ * setup, and false of every other machine — `config.toml` also holds:
807
+ *
808
+ * mode a machine reverting to `oss` stops reporting entirely
809
+ * daemon.configured the flag that makes the machine FAIL CLOSED
810
+ * collector.* sessions/hooks/verbosity/redact/environment/machine_id
811
+ * collector.sources.* every extra_paths a person typed
812
+ * audit.auto/interval the scheduled scan they switched on
813
+ *
814
+ * Losing `daemon.configured` silently downgrades a machine from fail-closed
815
+ * enforcement to the in-process path, and losing `mode` disconnects it from the
816
+ * fleet — neither with any message, and neither re-derivable without a human.
817
+ * Those are the same failures `HOME_CLASSES` stopped for `config.json`; this is
818
+ * the layout-2 leg of the same problem, in the other format.
819
+ *
820
+ * Read BEFORE the deletions (the source is on the retired list), applied after.
821
+ */
822
+ export function readCarriedLegacyConfig(): FpConfig | null {
823
+ const from = legacy.configToml();
824
+ if (!existsSync(from)) return null;
825
+ try {
826
+ return projectConfig(parseLegacyToml(readFileSync(from, "utf8")));
827
+ } catch {
828
+ // Unreadable is not worth aborting a reset over, and the telemetry carry
829
+ // below still gets its own chance at the same bytes.
830
+ return null;
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Carry the layout-2 `credentials.toml` across.
836
+ *
837
+ * This is the one that takes a machine off the fleet. `credentials.toml` is on
838
+ * the retired list and NOTHING carried it, so a layout-2 → 3 upgrade deleted the
839
+ * cloud token and the ingest key outright: the machine stops reconciling
840
+ * cloud-managed policy, stops delivering anything it spools, and says nothing —
841
+ * it keeps enforcing whatever it last had and keeps reporting healthy. On a fleet
842
+ * that is every box going quiet at once, with no operator action that caused it.
843
+ *
844
+ * `HOME_CLASSES` classes `credentials.json` `user-typed` so this cannot happen
845
+ * again from layout 3 onwards, but 2 → 3 is the upgrade that actually exists to
846
+ * be run, and it needed this.
847
+ *
848
+ * Written through `writeCredentials`, so the file lands 0600 with the home
849
+ * tightened to 0700 — a token must not arrive here by a path that skips that.
850
+ */
851
+ export function readCarriedLegacyCredentials(): FpCredentials | null {
852
+ // NEWEST SOURCE WINS, and both older layouts have to be handled here because
853
+ // BOTH of their credential files are on the retired list with nothing else
854
+ // carrying them:
855
+ //
856
+ // layout 2 — `credentials.toml`, one file, TOML
857
+ // layout 1 — `cloud.json` + `ingest.json`, two files, JSON, camelCase
858
+ //
859
+ // Layout 1 is the one that matters most in practice: the published `latest`
860
+ // npm tag is still a pre-daemon 0.0.x release, so "install the current stable,
861
+ // then upgrade" is a LAYOUT-1 → 3 migration, which makes this the upgrade real
862
+ // users actually perform. Losing the token there takes the machine off the
863
+ // fleet exactly as the layout-2 case does — silently, still enforcing whatever
864
+ // it last had, still reporting healthy.
865
+ const toml = legacy.credentialsToml();
866
+ if (existsSync(toml)) {
867
+ try {
868
+ const creds = projectCredentials(parseLegacyToml(readFileSync(toml, "utf8")));
869
+ // An empty object means the file held nothing that passed validation — a
870
+ // cloud table with no token, say. Writing that would create a credentials
871
+ // file that looks present and authenticates nothing.
872
+ if (Object.keys(creds).length > 0) return creds;
873
+ } catch {
874
+ // Fall through to layout 1's files rather than returning: an unreadable
875
+ // layout-2 file is not evidence that a layout-1 one is absent.
876
+ }
877
+ }
878
+
879
+ // Layout 1. `machineId` is CAMELCASE in `cloud.json` — layout 2 moved to
880
+ // snake_case — so the raw object is rebuilt in the shape `projectCredentials`
881
+ // reads rather than passed through. Reusing that projection is the point: "a
882
+ // cloud block without a token is not a cloud block" is a security property,
883
+ // and a second validator here is how a half-written credential comes to be
884
+ // treated as live.
885
+ const readJson = (path: string): Record<string, unknown> | null => {
886
+ try {
887
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
888
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
889
+ ? (parsed as Record<string, unknown>)
890
+ : null;
891
+ } catch {
892
+ return null;
893
+ }
894
+ };
895
+ const cloud = readJson(legacy.cloudCredentials());
896
+ const ingest = readJson(legacy.ingestCredentials());
897
+ if (!cloud && !ingest) return null;
898
+ const raw: Record<string, unknown> = {};
899
+ if (cloud) {
900
+ raw.cloud = {
901
+ url: cloud.url,
902
+ machine_id: cloud.machineId,
903
+ token: cloud.token,
904
+ // Layout 1 had no machine label; absent is correct rather than empty.
905
+ ...(typeof cloud.machineLabel === "string" ? { machine_label: cloud.machineLabel } : {}),
906
+ };
907
+ }
908
+ if (ingest) raw.ingest = { url: ingest.url, key: ingest.key };
909
+ const creds = projectCredentials(raw);
910
+ return Object.keys(creds).length > 0 ? creds : null;
911
+ }
912
+
913
+ export function resetHome(from: number): ResetOutcome {
914
+ // BEFORE the deletions, so a file that is mid-move is never one the reset
915
+ // then walks over.
916
+ const migrated = migrateConventionPolicies();
917
+ const activity = migrateHookActivity();
918
+ const pendingPolicyConfig = readCarriedPolicyConfig();
919
+ // Read before the deletions for the same reason as the policy config: their
920
+ // sources (`config.toml`, `credentials.toml`) are both on the list below.
921
+ const pendingConfig = readCarriedLegacyConfig();
922
+ const pendingCredentials = readCarriedLegacyCredentials();
923
+ const telemetryOptOut = readCarriedTelemetryOptOut();
924
+ // BEFORE the deletions, like every other carry here: both source directories are
925
+ // on the retired list below.
926
+ const spooled = migrateLegacySpool();
927
+ const removed: string[] = [];
928
+ for (const path of resettablePaths()) {
929
+ // The guard that stood here skipped `globalPolicyConfigFile()` when the
930
+ // reset was not a layout migration, because clearing a current, valid
931
+ // policy selection is only right when setup is about to re-ask. That path is
932
+ // `user-typed` in `HOME_CLASSES` now and therefore never on this list at
933
+ // all, for either kind of reset — so the special case has nothing left to
934
+ // guard. See the `legacy.policyConfig()` note in `retiredLayoutPaths()`.
935
+ if (!existsSync(path)) continue;
936
+ try {
937
+ rmSync(path, { recursive: true, force: true });
938
+ removed.push(path);
939
+ } catch {
940
+ // A path we cannot remove is not worth aborting the reset over; the
941
+ // VERSION stamp below is what stops this repeating, and a leftover
942
+ // directory from an old layout is inert once nothing reads it.
943
+ }
944
+ }
945
+ const policyConfig = writeCarriedPolicyConfig(pendingPolicyConfig);
946
+ // AFTER the carry, so a layout-2 value that happens to be named `collector`
947
+ // is retired too rather than surviving because it arrived a moment later.
948
+ retirePolicyConfigKeys();
949
+ // After the deletions, onto the fresh files, so the upgrade cannot revoke a
950
+ // choice the user made.
951
+ //
952
+ // The credentials go first and unconditionally: `daemon.configured` below makes
953
+ // the machine fail closed, and a machine that fails closed against a fleet it
954
+ // can no longer authenticate to is worse than either half alone.
955
+ if (pendingCredentials) writeCredentials(pendingCredentials);
956
+ if (pendingConfig) writeConfig(pendingConfig);
957
+ // AFTER the config carry, or a carried `telemetry.enabled: true` — the default,
958
+ // which is not a choice anyone made — would overwrite the opt-out this reads
959
+ // straight out of the same file. Both paths end at the same key, and only one
960
+ // of them represents something a person typed.
961
+ if (telemetryOptOut) updateConfig({ telemetry: { enabled: false } });
962
+ writeVersionFile();
963
+ return { removed, migrated, activity, policyConfig, spooled, from };
964
+ }
965
+
966
+ export interface LayoutCheck {
967
+ state: LayoutState;
968
+ /** Lines to print. Empty when there is nothing to say. */
969
+ lines: string[];
970
+ /** True when the caller should stop rather than continue. */
971
+ fatal: boolean;
972
+ /**
973
+ * True when this call actually migrated the home.
974
+ *
975
+ * **Reporting only. The caller must NOT force setup on it.**
976
+ *
977
+ * It used to mean "force the wizard", and the reason was a real gap: the reset
978
+ * removed the global policy config while `isConfigured()` is a union that also
979
+ * counts the agent CLIs' settings files — which the reset deliberately leaves
980
+ * alone — so the machine read as configured, the wizard was skipped, and
981
+ * `markLauncherSeen()` back-filled the marker so every later run skipped it
982
+ * too. The user was left with hooks firing on every tool call against no
983
+ * policies at all, and nothing ever said so again.
984
+ *
985
+ * That gap is closed at the source. `policies-config.json`, `config.json` and
986
+ * `credentials.json` are `user-typed` in `HOME_CLASSES` and are no longer
987
+ * removed, so after a migration `isConfigured()` is true because the machine
988
+ * genuinely IS configured — its policy selection, its `daemon.configured`
989
+ * flag and its cloud enrolment all survived. Everything the migration still
990
+ * drops (audit cache, cloud deployments, daemon scratch) is re-derived or
991
+ * re-fetched with nobody present.
992
+ *
993
+ * Forcing setup from here would now mean opening an interactive wizard for a
994
+ * machine with nothing to answer — and on the machines that matter most (a
995
+ * fleet box, a CI runner, a headless gateway) there is nobody to answer it. A
996
+ * home that genuinely never finished setup still reaches the wizard by the
997
+ * ordinary route: `isConfigured()` is false for it, so `shouldOfferFirstRun`
998
+ * fires on its own. That is why forcing was redundant even before it was wrong.
999
+ */
1000
+ didReset: boolean;
1001
+ }
1002
+
1003
+ /**
1004
+ * The interactive path: reset a stale home, refuse a future one.
1005
+ *
1006
+ * A future layout is refused rather than reset because the two failures are
1007
+ * not the same. An older home can be rebuilt by re-running setup; a home
1008
+ * written by a NEWER CLI holds data this build cannot read but a simple
1009
+ * upgrade could, and deleting it would destroy something recoverable.
1010
+ */
1011
+ /**
1012
+ * Deliver what is already spooled, immediately AFTER the migration.
1013
+ *
1014
+ * The order is the whole subtlety here, and the obvious one is wrong. Flushing
1015
+ * FIRST reads intuitive — get the events out before touching the disk — and it
1016
+ * cannot work: everything a flush needs to run is in a file the stale layout
1017
+ * hasn't got. `readConfig()` reads `config.json` and `readIngestCredential()`
1018
+ * reads `credentials.json`, both of which are LAYOUT-3 files, and a stale home by
1019
+ * definition has neither. Called before the migration, the flush would find no
1020
+ * ingest credential on every machine it ever ran on, refuse, and report nothing
1021
+ * pending — a step that looks like it protects data and is structurally incapable
1022
+ * of doing anything at all.
1023
+ *
1024
+ * Afterwards, both files exist: `readCarriedLegacyCredentials()` has just put the
1025
+ * token back and `readCarriedLegacyConfig()` the mode. So this runs where it can
1026
+ * actually succeed.
1027
+ *
1028
+ * What makes that safe rather than a gamble is that this is NOT the thing
1029
+ * protecting the events. `HOME_CLASSES` classes the spool `undelivered`, so it
1030
+ * survives the migration whatever happens here — losing it would be permanent,
1031
+ * because `cursors/` survives too and the watermark has already advanced past
1032
+ * every batch in it. This is the difference between "delivered" and "delivered on
1033
+ * the next collector pass", which matters only because the collector is unhurried
1034
+ * on purpose (a batch is swept once it is older than two minutes, at most 64 per
1035
+ * pass, on a 60-second cadence) and somebody standing at a dashboard cannot tell
1036
+ * "not yet" from "not working".
1037
+ *
1038
+ * BEST-EFFORT BY CONSTRUCTION. `runFlushCommand` refuses, with its own message
1039
+ * and a non-zero code, on every machine where a flush cannot work: collection
1040
+ * off, no ingest credential, an unsupported platform, no daemon listening. All of
1041
+ * those are ordinary here rather than errors, so the result is read for its COUNT
1042
+ * and its exit code discarded. Bounded at 30s: this sits in front of a command
1043
+ * the user typed, and a spool that will not drain is not a reason to hold their
1044
+ * terminal.
1045
+ */
1046
+ async function drainSpoolAfterMigrating(): Promise<number> {
1047
+ try {
1048
+ // Cheap gates first, so an OSS machine and one that never connected cost
1049
+ // nothing at all — no dynamic import, no daemon probe. Read AFTER the
1050
+ // migration, so these see the carried values rather than the defaults a
1051
+ // missing layout-3 file would have produced.
1052
+ const cfg = readConfig();
1053
+ if (cfg.mode !== "cloud") return 0;
1054
+ if (!cfg.collector.hooks && !cfg.collector.sessions) return 0;
1055
+ const { runFlushCommand } = await import("./flush-cli");
1056
+ const result = await runFlushCommand({ wait: true, timeoutSecs: 30 });
1057
+ return result.pending;
1058
+ } catch {
1059
+ // A flush that throws is not a migration failure. Reported as "nothing
1060
+ // pending" because the number only adds a line to a message, and inventing a
1061
+ // count from a failed probe would be worse than saying nothing.
1062
+ return 0;
1063
+ }
1064
+ }
1065
+
1066
+ export async function checkLayoutForCli(): Promise<LayoutCheck> {
1067
+ const state = detectLayout();
1068
+
1069
+ if (state.kind === "future") {
1070
+ return {
1071
+ state,
1072
+ fatal: true,
1073
+ didReset: false,
1074
+ lines: [
1075
+ `This machine's failproofai directory was written by a newer version`,
1076
+ `(layout ${state.found}; this build speaks ${LAYOUT_VERSION}).`,
1077
+ ``,
1078
+ `Upgrade rather than reset — the data is fine, this build just cannot read it:`,
1079
+ ` npm install -g failproofai@latest`,
1080
+ ],
1081
+ };
1082
+ }
1083
+
1084
+ if (state.kind === "stale") {
1085
+ // Through the registry, not `resetHome` directly. The two look identical for
1086
+ // today's layouts — the chain from 1 or 2 is one step, and that step IS
1087
+ // `resetHome` — and they stop being identical the moment a layout 4 exists,
1088
+ // at which point this call site needs no change. It also means every
1089
+ // migration this machine has ever run is recorded, and the irreplaceable
1090
+ // files are copied aside first. See `migrations.ts`.
1091
+ const { runMigrations } = await import("./migrations");
1092
+ const run = runMigrations(state.found);
1093
+ const { removed, migrated, activity } = run.outcome;
1094
+ // After, not before — see the function's own note for why the intuitive
1095
+ // order cannot work.
1096
+ const pending = await drainSpoolAfterMigrating();
1097
+ return {
1098
+ state,
1099
+ fatal: false,
1100
+ didReset: true,
1101
+ lines: [
1102
+ `failproofai reorganised ${failproofaiHome()} in this version.`,
1103
+ // "activity history" was in this sentence while the reset was deleting
1104
+ // it, and "policy config" stayed in it after that file stopped being
1105
+ // removed. Both are the same failure: a message describing a delete list
1106
+ // it is not derived from. It names the CLASSES now, which is what
1107
+ // `HOME_CLASSES` actually decides — so it cannot drift again without the
1108
+ // rule itself changing.
1109
+ `Removed ${removed.length} item(s) that this version rebuilds — the audit`,
1110
+ `cache, cloud deployments (re-fetched on the next poll) and daemon scratch.`,
1111
+ `Your settings, cloud enrolment, policy selection, decision history,`,
1112
+ `undelivered events and daemon binary were all kept.`,
1113
+ // Named individually rather than counted. These are files a person
1114
+ // wrote; "moved 3 items" is not something you can check at a glance,
1115
+ // and the whole point of saying it is that they can.
1116
+ ...(migrated.length > 0
1117
+ ? [
1118
+ ``,
1119
+ `Kept your own policy file(s) and moved them to where this version`,
1120
+ `loads them (${customPoliciesDir()}):`,
1121
+ ...migrated.map((name) => ` ${name}`),
1122
+ ]
1123
+ : []),
1124
+ // Counted, not named. Unlike policy files these are machine-written
1125
+ // pages with generated names — a list of them tells the reader nothing
1126
+ // they could act on, where the COUNT answers the only question they
1127
+ // have: did my history survive.
1128
+ ...(activity.length > 0
1129
+ ? [
1130
+ ``,
1131
+ `Carried ${activity.length} page(s) of decision history into ${hookActivityDir()}.`,
1132
+ ]
1133
+ : []),
1134
+ // Said only when a backlog actually survived the flush. Silence here
1135
+ // would be the wrong kind: the events are safe, but "safe" and
1136
+ // "delivered" are different states and only one of them shows up on a
1137
+ // dashboard. Naming the count is what stops a user reading an incomplete
1138
+ // dashboard as data loss.
1139
+ ...(pending > 0
1140
+ ? [
1141
+ ``,
1142
+ `${pending} batch(es) were still undelivered and were carried across.`,
1143
+ `They ship on the next collector pass — \`failproofai flush --wait\` now if you`,
1144
+ `are waiting on a dashboard.`,
1145
+ ]
1146
+ : []),
1147
+ // A step that threw leaves the home marked with the OLD layout, so the
1148
+ // next command tries again — which is right, and is also why this must
1149
+ // say so rather than let a partial migration pass for a finished one.
1150
+ ...(run.failed
1151
+ ? [
1152
+ ``,
1153
+ `Step ${run.failed.from} → ${run.failed.to} did not finish: ${run.failed.error}`,
1154
+ `The home is still marked layout ${state.found} and will be retried. Copies of`,
1155
+ `your settings and enrolment were saved first, in ${migrationBackupDir(state.found)}.`,
1156
+ ]
1157
+ : []),
1158
+ // No "run `failproofai config` to set up again". There is nothing to set
1159
+ // up: the settings, the enrolment and the policy selection all survived,
1160
+ // so the machine enforces exactly as it did before this command ran. A
1161
+ // home that genuinely never finished setup reaches the wizard through
1162
+ // `shouldOfferFirstRun`, which reads `isConfigured()` — see `didReset`.
1163
+ ],
1164
+ };
1165
+ }
1166
+
1167
+ // Write the marker back for a fresh home — and ALSO for one whose layout had
1168
+ // to be recovered from a landmark. `inferred` means the home is genuinely on
1169
+ // this layout and only `VERSION` is missing; leaving it missing means every
1170
+ // later command re-infers it, and `writeVersionFile()` carries the daemon
1171
+ // version forward from the file it just failed to read, so the recorded
1172
+ // daemon version is erased and `daemonVersionSkew()` goes quiet about a stale
1173
+ // daemon. Re-stamping is what "the only thing actually missing is the marker"
1174
+ // was supposed to mean.
1175
+ if (state.kind === "absent" || (state.kind === "current" && state.inferred)) writeVersionFile();
1176
+ return {
1177
+ state,
1178
+ fatal: false,
1179
+ didReset: false,
1180
+ lines: [...(await healDaemonFlag()), ...staleDaemonHint()],
1181
+ };
1182
+ }
1183
+
1184
+ /**
1185
+ * Clear `daemonConfigured` when the service it points at is provably gone.
1186
+ *
1187
+ * This exists because the combination it repairs bricked a real machine during
1188
+ * development. `daemonConfigured` makes every hook route through failproofaid
1189
+ * and FAIL CLOSED when it cannot be reached — so removing the service without
1190
+ * first clearing the flag denies every tool call on the box, including
1191
+ * `UserPromptSubmit`, which locks the user out of their agent entirely. There
1192
+ * was no command to undo it: the only recovery was hand-editing JSON.
1193
+ *
1194
+ * Deliberately keyed on "not-installed", never on "stopped". A stopped service
1195
+ * is usually a restart in progress, and clearing the flag there would silently
1196
+ * downgrade a healthy machine to the in-process path — trading a loud, correct
1197
+ * failure for a quiet, wrong one.
1198
+ */
1199
+ async function healDaemonFlag(): Promise<string[]> {
1200
+ try {
1201
+ const cfg = readConfig();
1202
+ if (!cfg.daemon.configured) return [];
1203
+ if (!isDaemonSupportedPlatform()) return [];
1204
+
1205
+ const status = daemonServiceStatus();
1206
+ if (status === "not-installed") {
1207
+ updateConfig({ daemon: { configured: false } });
1208
+ return [
1209
+ `failproofaid is no longer installed, but this machine was still configured`,
1210
+ `to require it — which denies every tool call. Cleared that flag; policies`,
1211
+ `now evaluate in-process. Run \`failproofai config\` to reinstall the daemon.`,
1212
+ ``,
1213
+ ];
1214
+ }
1215
+
1216
+ // Installed, and systemd has refused to start it: one of the paths the unit
1217
+ // is gated on is gone. This is what `npm rm -g failproofai` leaves behind —
1218
+ // npm runs no uninstall script, so the unit survives the package that
1219
+ // supplies its worker, and every tool call on the machine then denies with
1220
+ // nothing to point at.
1221
+ //
1222
+ // Treated like "not-installed" rather than like "stopped" because systemd
1223
+ // has already made the call and will keep making it at every boot. That is
1224
+ // the distinction `condition-failed` exists to carry; see its definition.
1225
+ if (status === "condition-failed") {
1226
+ updateConfig({ daemon: { configured: false } });
1227
+ return [
1228
+ `failproofaid is installed but cannot start — a file its service requires is`,
1229
+ `gone (most often because failproofai was removed with \`npm rm -g\`, which`,
1230
+ `deletes the worker but leaves the service behind). This machine was`,
1231
+ `configured to require the daemon, which denies every tool call, so that flag`,
1232
+ `is cleared; policies now evaluate in-process.`,
1233
+ ``,
1234
+ `Run \`failproofai uninstall\` to remove the leftover service, or`,
1235
+ `\`failproofai config\` to rebuild it. \`${daemonStatusCommand()}\` names the missing path.`,
1236
+ ``,
1237
+ ];
1238
+ }
1239
+
1240
+ // Installed and RUNNING is not the same as working, and the difference is
1241
+ // a total lockout. `ExecStart` bakes in `process.execPath`, so an
1242
+ // `nvm uninstall 20` months later leaves a unit systemd still calls active
1243
+ // whose worker dies on every spawn. Nothing else catches it: the install
1244
+ // probe cannot run retroactively, and the not-installed branch above never
1245
+ // fires because the unit is very much installed.
1246
+ //
1247
+ // Clearing the flag is the whole repair, and it is deliberately NOT
1248
+ // accompanied by an uninstall: this runs unprompted at the top of whatever
1249
+ // command the user typed, and tearing down a root-owned service from there
1250
+ // is not a decision to make on their behalf. Removing and reinstalling the
1251
+ // unit is the wizard's job, where a person is present — see the
1252
+ // `daemonAlreadyHealthy` probe in `configure-wizard.ts`.
1253
+ if (status === "running" && !(await probeDaemonEndToEnd())) {
1254
+ updateConfig({ daemon: { configured: false } });
1255
+ return [
1256
+ `failproofaid is running but cannot evaluate policies — its worker process`,
1257
+ `will not start (most often because the Node install its service was built`,
1258
+ `against is gone). This machine was configured to require it, which denies`,
1259
+ `every tool call, so that flag is cleared; policies now evaluate in-process.`,
1260
+ `Run \`failproofai config\` to rebuild the service.`,
1261
+ ``,
1262
+ ];
1263
+ }
1264
+ return [];
1265
+ } catch {
1266
+ // Never let a self-heal attempt break the command the user actually typed.
1267
+ return [];
1268
+ }
1269
+ }
1270
+
1271
+ /**
1272
+ * One line when the daemon is older than the CLI.
1273
+ *
1274
+ * Deliberately NOT on the hook path. A stale daemon still enforces every policy
1275
+ * correctly — it is slower to notice an upgrade, not broken — so a warning once
1276
+ * per tool call would be noise about something that is working. CLI commands
1277
+ * are where a person is present to act on it.
1278
+ */
1279
+ function staleDaemonHint(): string[] {
1280
+ try {
1281
+ const skew = daemonVersionSkew();
1282
+ if (!skew) return [];
1283
+ return [
1284
+ `[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected} — ` +
1285
+ `run \`failproofai config\` to update it.`,
1286
+ ``,
1287
+ ];
1288
+ } catch {
1289
+ return [];
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * The hook path: never delete, never deny, but never stay quiet either.
1295
+ *
1296
+ * Returns a single stderr line when the layout is not current. Hooks run once
1297
+ * per tool call, so this repeats — deliberately. A warning that appears once
1298
+ * and then stops is a warning nobody sees, and the state it describes (a
1299
+ * machine whose global policies have silently stopped firing) persists until
1300
+ * somebody acts on it.
1301
+ */
1302
+ /**
1303
+ * Why an UNATTENDED run must not proceed — a different question from the hook's.
1304
+ *
1305
+ * The hook asks "are global policies unenforced?", and answers it about one
1306
+ * file. This asks "is this home on a layout this build understands?", and the
1307
+ * answer must not be softened by anything: the scheduled audit runs with nobody
1308
+ * watching, and the ordinary CLI path RESETS a stale home — which deletes
1309
+ * `config.toml` (revoking a telemetry opt-out and the `[audit] auto` flag that
1310
+ * scheduled the run) and `credentials.toml` (dropping cloud enrolment). Doing
1311
+ * that on a timer, with the explanation going only to the service journal, is
1312
+ * the failure this gate exists to prevent.
1313
+ *
1314
+ * Sharing `layoutWarningForHook()` for both is what made this subtle: teaching
1315
+ * that function to stay quiet when the global policy config IS readable — right
1316
+ * for the hook, since layout 1 and layout 3 keep it at the same path — silently
1317
+ * turned this gate off for exactly the homes most likely to hit it.
1318
+ */
1319
+ export function layoutBlockerForScheduledRun(): string | null {
1320
+ const state = detectLayout();
1321
+ if (state.kind === "current" || state.kind === "absent") return null;
1322
+ if (state.kind === "future") {
1323
+ return (
1324
+ `[failproofai] this directory was written by a newer version ` +
1325
+ `(layout ${state.found} vs ${LAYOUT_VERSION}) — refusing to run unattended. ` +
1326
+ `Upgrade failproofai.`
1327
+ );
1328
+ }
1329
+ return (
1330
+ `[failproofai] setup predates this version (layout ${state.found} vs ${LAYOUT_VERSION}) — ` +
1331
+ `refusing to run unattended, because completing it would reset this home. ` +
1332
+ `Run \`failproofai config\`.`
1333
+ );
1334
+ }
1335
+
1336
+ export function layoutWarningForHook(): string | null {
1337
+ const state = detectLayout();
1338
+ if (state.kind === "current" || state.kind === "absent") return null;
1339
+ if (state.kind === "future") {
1340
+ return (
1341
+ `[failproofai] this directory was written by a newer version ` +
1342
+ `(layout ${state.found} vs ${LAYOUT_VERSION}) — policies are NOT being enforced. ` +
1343
+ `Upgrade failproofai.`
1344
+ );
1345
+ }
1346
+ // The claim is about ONE FILE — the global policy config — so ask about that
1347
+ // file rather than about the layout number. Layout 1 and layout 3 keep it at
1348
+ // the SAME path (`~/.failproofai/policies-config.json`), so a home carrying
1349
+ // one is read as layout 1 by the landmark fallback while the hook path loads
1350
+ // and enforces it perfectly well. Hand-writing that file is something
1351
+ // `docs/configuration.mdx` explicitly tells people to do, and they were told
1352
+ // on every single tool call that their policies were not being enforced
1353
+ // WHILE THEY WERE — the deny and the warning printed together.
1354
+ //
1355
+ // A warning that contradicts the behaviour it describes is worse than no
1356
+ // warning: it teaches people to ignore the one channel that will matter when
1357
+ // enforcement really has stopped.
1358
+ if (existsSync(globalPolicyConfigFile())) return null;
1359
+ return (
1360
+ `[failproofai] setup predates this version — global policies are NOT being enforced. ` +
1361
+ `Run \`failproofai config\` to re-create it.`
1362
+ );
1363
+ }