failproofai 1.0.0-beta.14 → 1.0.0-beta.16

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 (190) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js +2 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  13. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  16. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  18. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page.js +2 -1
  20. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  23. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  25. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  27. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/api/audit/invite/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/logout/route.js +1 -1
  38. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +1 -1
  40. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/auth/status/route.js +1 -1
  42. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  46. package/.next/standalone/.next/server/app/audit/page.js +2 -1
  47. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/index.html +1 -1
  50. package/.next/standalone/.next/server/app/index.rsc +14 -14
  51. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  52. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  53. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  54. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  55. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  56. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  57. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/page.js +2 -1
  59. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  63. package/.next/standalone/.next/server/app/policies/page.js +2 -1
  64. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -1
  69. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  73. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  74. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -1
  75. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  76. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  77. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  78. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  79. package/.next/standalone/.next/server/app/projects/page.js +2 -1
  80. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  81. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  82. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  83. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  84. package/.next/standalone/.next/server/app/settings/page.js +2 -1
  85. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  86. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  87. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +3 -0
  88. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +3 -0
  92. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hxmox-._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12y_xo0._.js → [root-of-the-server]__0no2gzh._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{_202d777._.js → _09tcu2b._.js} +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  109. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  112. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  113. package/.next/standalone/.next/server/pages/404.html +1 -1
  114. package/.next/standalone/.next/server/pages/500.html +1 -1
  115. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  116. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  117. package/.next/standalone/.next/static/chunks/{0mot4rr-baiiq.js → 0fx3tgb_n0vz5.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{1_0nr2fuuslbb.js → 16yzosmvpinbh.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{0ynhunx0nt0n-.js → 1_as8528fwqxf.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/{1tyg866lrq4kh.js → 1nsy22eg5q-pi.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{2zilodg05qgcf.js → 2f2h7g8sm0bhr.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{0pkz6r-cgdr3-.js → 2n6wkcj5q_0yd.js} +2 -2
  123. package/.next/standalone/.next/static/chunks/{30f--ye1ly8m6.js → 2okcei5v0-xrx.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/{31fmsp1soxi07.js → 2rl5gq4xl8oa2.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{1wn2p7yzbcifa.js → 3vsiw3ayz3c7_.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/{2hiai8kvhjcov.js → 3xp43uu8jj8t4.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/43kqvfa-2jq0g.js +1 -0
  128. package/.next/standalone/.next/static/chunks/{turbopack-3diub4ctvn1n0.js → turbopack-0ud32mdjjcn6y.js} +1 -1
  129. package/.next/standalone/SECURITY.md +8 -2
  130. package/.next/standalone/app/actions/get-hooks-config.ts +0 -1
  131. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -3
  132. package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +1 -1
  133. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +1 -1
  134. package/.next/standalone/app/audit/_components/empty-state.tsx +0 -1
  135. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +1 -1
  136. package/.next/standalone/app/audit/_components/quirks-section.tsx +0 -1
  137. package/.next/standalone/app/audit/_components/report-footer.tsx +0 -2
  138. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  139. package/.next/standalone/app/audit/_components/strengths-section.tsx +0 -1
  140. package/.next/standalone/app/components/pause-notices.tsx +1 -1
  141. package/.next/standalone/app/components/raw-log-viewer.tsx +1 -1
  142. package/.next/standalone/app/components/toast.tsx +1 -1
  143. package/.next/standalone/app/policies/hooks-client.tsx +3 -2
  144. package/.next/standalone/app/settings/settings-client.tsx +1 -2
  145. package/.next/standalone/lib/auth/auth-store.ts +0 -1
  146. package/.next/standalone/package.json +10 -12
  147. package/.next/standalone/server.js +1 -1
  148. package/bin/failproofai.mjs +253 -33
  149. package/bin/failproofaid-shim.mjs +4 -4
  150. package/dist/cli.mjs +6337 -3869
  151. package/dist/worker.mjs +26 -21
  152. package/lib/auth/auth-store.ts +0 -1
  153. package/package.json +10 -12
  154. package/scripts/dev-hook.mjs +46 -5
  155. package/scripts/translate-docs/cache.ts +1 -1
  156. package/scripts/translate-docs/cli.ts +2 -2
  157. package/scripts/translate-docs/mintlify-nav.ts +1 -1
  158. package/src/audit/cache.ts +0 -1
  159. package/src/audit/dashboard-cache.ts +0 -2
  160. package/src/audit/report.ts +0 -1
  161. package/src/hooks/builtin-policies.ts +1 -1
  162. package/src/hooks/cloud-enrollment-cli.ts +0 -2
  163. package/src/hooks/cloud-enrollment.ts +1 -2
  164. package/src/hooks/cloud-managed-policies.ts +0 -1
  165. package/src/hooks/collector-config.ts +4 -20
  166. package/src/hooks/configure-wizard.ts +0 -4
  167. package/src/hooks/custom-hooks-loader.ts +0 -1
  168. package/src/hooks/daemon-client.ts +4 -28
  169. package/src/hooks/daemon-download.ts +0 -1
  170. package/src/hooks/daemon-service.ts +108 -16
  171. package/src/hooks/fp-config.ts +226 -12
  172. package/src/hooks/fp-home.ts +270 -56
  173. package/src/hooks/fp-reset.ts +482 -73
  174. package/src/hooks/hook-logger.ts +0 -1
  175. package/src/hooks/integrations.ts +0 -1
  176. package/src/hooks/loader-utils.ts +1 -1
  177. package/src/hooks/manager.ts +1 -1
  178. package/src/hooks/migrations.ts +381 -0
  179. package/src/hooks/onboarding-lock.ts +1 -2
  180. package/src/hooks/session-pause.ts +0 -1
  181. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1uyc-z4._.js +0 -3
  182. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0vlyx3j._.js +0 -3
  183. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +0 -5
  184. package/.next/standalone/.next/static/chunks/2yy1767yfda67.js +0 -1
  185. package/.next/standalone/ino.ujh3/.failproofai/VERSION +0 -4
  186. package/.next/standalone/ino.ujh3/.failproofai/state/last-version +0 -1
  187. package/.next/standalone/ino.ujh3/.failproofai/state/telemetry-id +0 -1
  188. /package/.next/standalone/.next/static/{ZbPDZe0dNBNdNmo31aq_e → kSTfP_Tzl760gYDnYr-JC}/_buildManifest.js +0 -0
  189. /package/.next/standalone/.next/static/{ZbPDZe0dNBNdNmo31aq_e → kSTfP_Tzl760gYDnYr-JC}/_clientMiddlewareManifest.js +0 -0
  190. /package/.next/standalone/.next/static/{ZbPDZe0dNBNdNmo31aq_e → kSTfP_Tzl760gYDnYr-JC}/_ssgManifest.js +0 -0
@@ -2,11 +2,33 @@
2
2
  * What happens when this CLI meets a home directory written by a different
3
3
  * layout.
4
4
  *
5
- * The decision was wipe-and-re-setup rather than migrate. A migration has to
6
- * be right on every path or it half-moves a home, and a half-moved home fails
7
- * in the worst available way: the daemon writes where the dashboard does not
8
- * read, and an absent directory is indistinguishable from an idle one. A reset
9
- * is one destructive operation that is either done or not done.
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.
10
32
  *
11
33
  * ## Where the deletion happens, and where it deliberately does not
12
34
  *
@@ -49,10 +71,23 @@ import {
49
71
  globalPolicyConfigFile,
50
72
  hookActivityDir,
51
73
  legacy,
74
+ migrationBackupDir,
52
75
  policiesDir,
53
76
  resettablePaths,
54
77
  } from "./fp-home";
55
- import { detectLayout, readConfig, updateConfig, writeVersionFile, type LayoutState } from "./fp-config";
78
+ import {
79
+ detectLayout,
80
+ projectConfig,
81
+ projectCredentials,
82
+ readConfig,
83
+ updateConfig,
84
+ writeConfig,
85
+ writeCredentials,
86
+ writeVersionFile,
87
+ type FpConfig,
88
+ type FpCredentials,
89
+ type LayoutState,
90
+ } from "./fp-config";
56
91
  import {
57
92
  daemonServiceStatus,
58
93
  daemonStatusCommand,
@@ -278,30 +313,23 @@ export function migrateHookActivity(): string[] {
278
313
  }
279
314
 
280
315
  /**
281
- * Fields of layout 1's `policies-config.json` that layout 2's still means.
316
+ * The eight keys the layout-2 carry used to move, kept only as a record.
317
+ *
318
+ * It was an ALLOWLIST, and that was the bug: anything outside these eight names
319
+ * was dropped, including a key a NEWER build had written into a layout-2 file.
320
+ * The carry preserves every key now and deletes only the retired ones
321
+ * (`RETIRED_POLICY_CONFIG_KEYS`), which is the same rule `writeConfig` follows for
322
+ * `config.json` — unowned keys survive, dead ones go.
282
323
  *
283
- * An allowlist, not a copy, and the exclusion is the point: layout 1's file
284
- * ALSO carried a `collector` block, and layout 2 moved collector settings to
285
- * `[collector]` in `config.toml` — in snake_case, deliberately, because
324
+ * The one exclusion this list existed for is still enforced, by that other
325
+ * constant: layout 1's file also carried a `collector` block, and layout 2 moved
326
+ * collector settings to `config.toml` in snake_case — deliberately, because
286
327
  * `fpai-collect`'s `Settings` deserializes them and camelCase keys would make
287
328
  * every field silently fall back to its default (see the note on `Settings` in
288
329
  * `crates/fpai-collect/src/config.rs`, which records that exact bug). Carrying
289
330
  * `collector` forward would put a block into the new file that nothing reads,
290
- * where it would look like a preserved setting and behave like an absent one.
291
- *
292
- * The `llm` block moves because the loader still reads it from here, and its
293
- * value is an endpoint and a model name a person configured by hand.
331
+ * looking like a preserved setting and behaving like an absent one.
294
332
  */
295
- const CARRIED_POLICY_CONFIG_KEYS = [
296
- "enabledPolicies",
297
- "customPoliciesPaths",
298
- "customPoliciesPath",
299
- "disabledCustomPolicies",
300
- "conventionPolicies",
301
- "disabledConventionPolicies",
302
- "policyParams",
303
- "llm",
304
- ] as const;
305
333
 
306
334
  /**
307
335
  * Carry the user's policy SELECTION across the layout-1 → layout-2 move.
@@ -336,20 +364,75 @@ const CARRIED_POLICY_CONFIG_KEYS = [
336
364
  *
337
365
  * So: READ before, WRITE after.
338
366
  */
367
+ /**
368
+ * Keys the policy config used to hold that nothing reads any more.
369
+ *
370
+ * `collector` is the whole list, and it is why the layout-1 carry was an
371
+ * ALLOWLIST rather than a copy: layout 1 kept collector settings here in
372
+ * camelCase, and layout 2 moved them to `config.toml`/`config.json` in
373
+ * snake_case, where `fpai-collect`'s `Settings` deserializes them. A camelCase
374
+ * `collector` block sitting in the layout-3 file reads as a preserved setting and
375
+ * does nothing — the worst of both, because it looks answered.
376
+ *
377
+ * A NAMED list, not "everything outside the keep-list". The file survives the
378
+ * reset now (`HOME_CLASSES` classes it `user-typed`), so dropping by exclusion
379
+ * would delete every key a NEWER build had written — which is the bug Phase 1
380
+ * fixed for `config.json`, arriving here by a different door.
381
+ */
382
+ const RETIRED_POLICY_CONFIG_KEYS = ["collector"] as const;
383
+
384
+ /**
385
+ * Remove the retired keys from the policy config, in place.
386
+ *
387
+ * Runs regardless of which layout we came from: a key is retired or it is not,
388
+ * and a layout-2 home carrying a stray layout-1 root file should be cleaned the
389
+ * same way. Returns what it removed.
390
+ *
391
+ * The file is DELETED if stripping empties it — a `{}` policy config is not a
392
+ * selection, and the old behaviour for a file holding nothing but `collector`
393
+ * was no file at all.
394
+ */
395
+ export function retirePolicyConfigKeys(): string[] {
396
+ const at = globalPolicyConfigFile();
397
+ let parsed: Record<string, unknown>;
398
+ try {
399
+ const raw = JSON.parse(readFileSync(at, "utf8")) as unknown;
400
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
401
+ parsed = raw as Record<string, unknown>;
402
+ } catch {
403
+ // Absent or unparseable. Not worth aborting a reset over, and there is
404
+ // nothing to retire — the same answer the carry gives for the same input.
405
+ return [];
406
+ }
407
+ const removed = RETIRED_POLICY_CONFIG_KEYS.filter((k) => parsed[k] !== undefined);
408
+ if (removed.length === 0) return [];
409
+ for (const key of removed) delete parsed[key];
410
+ try {
411
+ if (Object.keys(parsed).length === 0) rmSync(at, { force: true });
412
+ else writeFileSync(at, JSON.stringify(parsed, null, 2) + "\n", "utf8");
413
+ } catch {
414
+ // A file we cannot rewrite keeps its dead key. Misleading, but not fatal,
415
+ // and not worth failing the migration over.
416
+ return [];
417
+ }
418
+ return [...removed];
419
+ }
420
+
339
421
  export function readCarriedPolicyConfig(): Record<string, unknown> | null {
340
- const to = globalPolicyConfigFile();
341
- // NEWEST SOURCE WINS. Two older layouts kept this file in two places, and a
342
- // home being upgraded may hold either:
422
+ // LAYOUT 2's NESTED COPY ONLY — `policies/local-policies/policies-config.json`.
343
423
  //
344
- // layout 2 — `policies/local-policies/policies-config.json`
345
- // layout 1 — `policies-config.json` at the home root
424
+ // This used to prefer that path and fall back to `legacy.policyConfig()`, the
425
+ // layout-1 file at the home root. That fallback is gone because the file is:
426
+ // layout 3 puts the live config back at exactly that path, `HOME_CLASSES`
427
+ // classes it `user-typed`, and it is no longer deleted — so a layout-1 home
428
+ // keeps it untouched, with EVERY key rather than the eight this function knows
429
+ // to carry. Reading it here and writing it back would be the only thing that
430
+ // could still narrow it.
346
431
  //
347
- // Layout 3 puts it back at the ROOT, so layout 1's path and the destination
348
- // are now the same file. Reading layout 1's copy while a layout-2 one exists
349
- // would carry the OLDER answer forward and silently undo whatever the user
350
- // chose since — so layout 2's nested file is preferred whenever it is there.
351
- const layoutTwo = resolve(legacy.localPoliciesDir(), "policies-config.json");
352
- const from = existsSync(layoutTwo) ? layoutTwo : legacy.policyConfig();
432
+ // Layout 2's copy is different: `legacy.localPoliciesDir()` is on the retired
433
+ // list and really is deleted, so without this it is lost. A home cannot hold
434
+ // both — layout 2 never wrote the root file, and going 1 → 2 removed it.
435
+ const from = resolve(legacy.localPoliciesDir(), "policies-config.json");
353
436
  if (!existsSync(from)) return null;
354
437
 
355
438
  let parsed: Record<string, unknown>;
@@ -363,17 +446,23 @@ export function readCarriedPolicyConfig(): Record<string, unknown> | null {
363
446
  }
364
447
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
365
448
 
449
+ // EVERY key except the retired ones. This was an eight-name ALLOWLIST, and it
450
+ // dropped anything outside it — including a key a NEWER build had written into a
451
+ // layout-2 file, which is the same loss Phase 1 fixed for `config.json` arriving
452
+ // by a third door. Caught by a smoke test on a seeded home: a `futureKey` in the
453
+ // nested config was gone after the migration, silently.
454
+ //
455
+ // Retired keys still go, because they are dead rather than unknown — see
456
+ // `RETIRED_POLICY_CONFIG_KEYS` for why leaving `collector` here is worse than
457
+ // deleting it.
366
458
  const carried: Record<string, unknown> = {};
367
- let found = false;
368
- for (const key of CARRIED_POLICY_CONFIG_KEYS) {
369
- if (parsed[key] !== undefined) {
370
- carried[key] = parsed[key];
371
- found = true;
372
- }
459
+ for (const [key, value] of Object.entries(parsed)) {
460
+ if ((RETIRED_POLICY_CONFIG_KEYS as readonly string[]).includes(key)) continue;
461
+ carried[key] = value;
373
462
  }
374
- // Nothing worth carrying — a file holding only excluded fields must not
375
- // produce an empty layout-2 config that looks like a real one.
376
- if (!found) return null;
463
+ // Nothing worth carrying — a file holding only retired fields must not produce
464
+ // an empty config that looks like a real one.
465
+ if (Object.keys(carried).length === 0) return null;
377
466
 
378
467
  // `enabledPolicies` is required by the type and by every reader. A layout-1
379
468
  // file that somehow lacked it would otherwise produce a layout-2 file that
@@ -382,13 +471,30 @@ export function readCarriedPolicyConfig(): Record<string, unknown> | null {
382
471
  return carried;
383
472
  }
384
473
 
385
- /** Second phase of {@link readCarriedPolicyConfig}; runs AFTER the deletions. */
474
+ /**
475
+ * Second phase of {@link readCarriedPolicyConfig}; runs AFTER the deletions.
476
+ *
477
+ * MERGES onto whatever is at the destination rather than replacing it. The
478
+ * destination is `user-typed` now and survives the reset, so a plain write would
479
+ * truncate a live file down to the keys this carry happens to know about — the
480
+ * same class of loss the carry exists to prevent, arriving by the other door.
481
+ * The carried values still win: they are the ones being migrated.
482
+ */
386
483
  export function writeCarriedPolicyConfig(carried: Record<string, unknown> | null): string[] {
387
484
  if (!carried) return [];
388
485
  const to = globalPolicyConfigFile();
486
+ let existing: Record<string, unknown> = {};
487
+ try {
488
+ const parsed = JSON.parse(readFileSync(to, "utf8")) as unknown;
489
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
490
+ existing = parsed as Record<string, unknown>;
491
+ }
492
+ } catch {
493
+ // Absent or unparseable — the carry is the whole file, as before.
494
+ }
389
495
  try {
390
496
  mkdirSync(dirname(to), { recursive: true });
391
- writeFileSync(to, JSON.stringify(carried, null, 2) + "\n", "utf8");
497
+ writeFileSync(to, JSON.stringify({ ...existing, ...carried }, null, 2) + "\n", "utf8");
392
498
  } catch {
393
499
  // A destination we cannot write is not worth aborting the reset over; the
394
500
  // user re-runs setup, which is the pre-existing behaviour.
@@ -455,25 +561,201 @@ export function readCarriedTelemetryOptOut(): boolean {
455
561
  }
456
562
  }
457
563
 
564
+ /**
565
+ * Parse the TOML subset layout 2 actually wrote, into the object shape layout 3
566
+ * parses out of JSON.
567
+ *
568
+ * NOT a TOML implementation, and it does not need to be: layout 2's `config.toml`
569
+ * and `credentials.toml` were written by this codebase, by two functions that
570
+ * emitted nothing but `[table]` / `[dotted.table]` headers and `key = <value>`
571
+ * lines where every value went through `JSON.stringify`. So `JSON.parse` on the
572
+ * right-hand side is EXACT rather than approximate — strings, booleans, numbers
573
+ * and the one array (`extra_paths`) all round-trip by construction. Removing the
574
+ * `toml` dependency was half the point of layout 3; adding it back to read two
575
+ * files we wrote ourselves would undo that.
576
+ *
577
+ * The output uses layout 2's own snake_case key names, unchanged, because layout
578
+ * 3's JSON uses exactly the same ones (`hooks_verbosity`, `machine_id`,
579
+ * `interval_days`, `sources.<h>.extra_paths`). That is what lets the carry run
580
+ * `projectConfig` / `projectCredentials` — the SAME projections the JSON readers
581
+ * use — instead of a second reader that would have to be kept in step. The only
582
+ * thing that differs between the two layouts is how bytes become an object.
583
+ *
584
+ * A dotted header nests: `[collector.sources.claude]` lands at
585
+ * `collector.sources.claude`, which is where `readSources` looks for it.
586
+ * Anything unparseable is skipped rather than throwing, because a single
587
+ * malformed line must not cost the user the whole file.
588
+ */
589
+ export function parseLegacyToml(text: string): Record<string, unknown> {
590
+ const root: Record<string, unknown> = {};
591
+ let table = root;
592
+ for (const raw of text.split("\n")) {
593
+ const line = raw.trim();
594
+ if (line === "" || line.startsWith("#")) continue;
595
+ const header = /^\[([^\]]+)\]$/.exec(line);
596
+ if (header) {
597
+ table = root;
598
+ for (const part of header[1].split(".")) {
599
+ const key = part.trim();
600
+ if (!key) break;
601
+ const existing = table[key];
602
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
603
+ table = existing as Record<string, unknown>;
604
+ } else {
605
+ const created: Record<string, unknown> = {};
606
+ table[key] = created;
607
+ table = created;
608
+ }
609
+ }
610
+ continue;
611
+ }
612
+ const eq = line.indexOf("=");
613
+ if (eq === -1) continue;
614
+ const key = line.slice(0, eq).trim();
615
+ if (!key) continue;
616
+ try {
617
+ table[key] = JSON.parse(line.slice(eq + 1).trim()) as unknown;
618
+ } catch {
619
+ // A value this codebase did not write, or a hand-edit that broke it. Skip
620
+ // the line; the projection then falls back to that field's default, which
621
+ // is the same answer an absent key gets.
622
+ }
623
+ }
624
+ return root;
625
+ }
626
+
627
+ /**
628
+ * Carry the whole layout-2 `config.toml` across, not just the telemetry opt-out.
629
+ *
630
+ * `readCarriedTelemetryOptOut()` above rescued one field, and the reason it was
631
+ * only one was that the rest was described as "re-derived by setup or a thing the
632
+ * wizard re-asks". That is true of a machine whose owner is about to re-run
633
+ * setup, and false of every other machine — `config.toml` also holds:
634
+ *
635
+ * mode a machine reverting to `oss` stops reporting entirely
636
+ * daemon.configured the flag that makes the machine FAIL CLOSED
637
+ * collector.* sessions/hooks/verbosity/redact/environment/machine_id
638
+ * collector.sources.* every extra_paths a person typed
639
+ * audit.auto/interval the scheduled scan they switched on
640
+ *
641
+ * Losing `daemon.configured` silently downgrades a machine from fail-closed
642
+ * enforcement to the in-process path, and losing `mode` disconnects it from the
643
+ * fleet — neither with any message, and neither re-derivable without a human.
644
+ * Those are the same failures `HOME_CLASSES` stopped for `config.json`; this is
645
+ * the layout-2 leg of the same problem, in the other format.
646
+ *
647
+ * Read BEFORE the deletions (the source is on the retired list), applied after.
648
+ */
649
+ export function readCarriedLegacyConfig(): FpConfig | null {
650
+ const from = legacy.configToml();
651
+ if (!existsSync(from)) return null;
652
+ try {
653
+ return projectConfig(parseLegacyToml(readFileSync(from, "utf8")));
654
+ } catch {
655
+ // Unreadable is not worth aborting a reset over, and the telemetry carry
656
+ // below still gets its own chance at the same bytes.
657
+ return null;
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Carry the layout-2 `credentials.toml` across.
663
+ *
664
+ * This is the one that takes a machine off the fleet. `credentials.toml` is on
665
+ * the retired list and NOTHING carried it, so a layout-2 → 3 upgrade deleted the
666
+ * cloud token and the ingest key outright: the machine stops reconciling
667
+ * cloud-managed policy, stops delivering anything it spools, and says nothing —
668
+ * it keeps enforcing whatever it last had and keeps reporting healthy. On a fleet
669
+ * that is every box going quiet at once, with no operator action that caused it.
670
+ *
671
+ * `HOME_CLASSES` classes `credentials.json` `user-typed` so this cannot happen
672
+ * again from layout 3 onwards, but 2 → 3 is the upgrade that actually exists to
673
+ * be run, and it needed this.
674
+ *
675
+ * Written through `writeCredentials`, so the file lands 0600 with the home
676
+ * tightened to 0700 — a token must not arrive here by a path that skips that.
677
+ */
678
+ export function readCarriedLegacyCredentials(): FpCredentials | null {
679
+ // NEWEST SOURCE WINS, and both older layouts have to be handled here because
680
+ // BOTH of their credential files are on the retired list with nothing else
681
+ // carrying them:
682
+ //
683
+ // layout 2 — `credentials.toml`, one file, TOML
684
+ // layout 1 — `cloud.json` + `ingest.json`, two files, JSON, camelCase
685
+ //
686
+ // Layout 1 is the one that matters most in practice: the published `latest`
687
+ // npm tag is still a pre-daemon 0.0.x release, so "install the current stable,
688
+ // then upgrade" is a LAYOUT-1 → 3 migration, which makes this the upgrade real
689
+ // users actually perform. Losing the token there takes the machine off the
690
+ // fleet exactly as the layout-2 case does — silently, still enforcing whatever
691
+ // it last had, still reporting healthy.
692
+ const toml = legacy.credentialsToml();
693
+ if (existsSync(toml)) {
694
+ try {
695
+ const creds = projectCredentials(parseLegacyToml(readFileSync(toml, "utf8")));
696
+ // An empty object means the file held nothing that passed validation — a
697
+ // cloud table with no token, say. Writing that would create a credentials
698
+ // file that looks present and authenticates nothing.
699
+ if (Object.keys(creds).length > 0) return creds;
700
+ } catch {
701
+ // Fall through to layout 1's files rather than returning: an unreadable
702
+ // layout-2 file is not evidence that a layout-1 one is absent.
703
+ }
704
+ }
705
+
706
+ // Layout 1. `machineId` is CAMELCASE in `cloud.json` — layout 2 moved to
707
+ // snake_case — so the raw object is rebuilt in the shape `projectCredentials`
708
+ // reads rather than passed through. Reusing that projection is the point: "a
709
+ // cloud block without a token is not a cloud block" is a security property,
710
+ // and a second validator here is how a half-written credential comes to be
711
+ // treated as live.
712
+ const readJson = (path: string): Record<string, unknown> | null => {
713
+ try {
714
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
715
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
716
+ ? (parsed as Record<string, unknown>)
717
+ : null;
718
+ } catch {
719
+ return null;
720
+ }
721
+ };
722
+ const cloud = readJson(legacy.cloudCredentials());
723
+ const ingest = readJson(legacy.ingestCredentials());
724
+ if (!cloud && !ingest) return null;
725
+ const raw: Record<string, unknown> = {};
726
+ if (cloud) {
727
+ raw.cloud = {
728
+ url: cloud.url,
729
+ machine_id: cloud.machineId,
730
+ token: cloud.token,
731
+ // Layout 1 had no machine label; absent is correct rather than empty.
732
+ ...(typeof cloud.machineLabel === "string" ? { machine_label: cloud.machineLabel } : {}),
733
+ };
734
+ }
735
+ if (ingest) raw.ingest = { url: ingest.url, key: ingest.key };
736
+ const creds = projectCredentials(raw);
737
+ return Object.keys(creds).length > 0 ? creds : null;
738
+ }
739
+
458
740
  export function resetHome(from: number): ResetOutcome {
459
741
  // BEFORE the deletions, so a file that is mid-move is never one the reset
460
742
  // then walks over.
461
743
  const migrated = migrateConventionPolicies();
462
744
  const activity = migrateHookActivity();
463
745
  const pendingPolicyConfig = readCarriedPolicyConfig();
464
- // Read before the deletions for the same reason as the policy config: its
465
- // source (`config.toml`) is on the list below.
746
+ // Read before the deletions for the same reason as the policy config: their
747
+ // sources (`config.toml`, `credentials.toml`) are both on the list below.
748
+ const pendingConfig = readCarriedLegacyConfig();
749
+ const pendingCredentials = readCarriedLegacyCredentials();
466
750
  const telemetryOptOut = readCarriedTelemetryOptOut();
467
751
  const removed: string[] = [];
468
752
  for (const path of resettablePaths()) {
469
- // A reset FROM the current layout is not a layout migration, and the only
470
- // thing under `local-policies/` is the user's enabled-policy selection.
471
- // Clearing it is right when moving off an old layout (setup re-asks) and
472
- // wrong when nothing is being migrated — there it would silently discard a
473
- // current, valid selection, including one this function had just carried.
474
- // The single production caller always passes a DETECTED stale layout, so
475
- // this only ever changes the forced same-layout case.
476
- if (from === LAYOUT_VERSION && path === globalPolicyConfigFile()) continue;
753
+ // The guard that stood here skipped `globalPolicyConfigFile()` when the
754
+ // reset was not a layout migration, because clearing a current, valid
755
+ // policy selection is only right when setup is about to re-ask. That path is
756
+ // `user-typed` in `HOME_CLASSES` now and therefore never on this list at
757
+ // all, for either kind of reset — so the special case has nothing left to
758
+ // guard. See the `legacy.policyConfig()` note in `retiredLayoutPaths()`.
477
759
  if (!existsSync(path)) continue;
478
760
  try {
479
761
  rmSync(path, { recursive: true, force: true });
@@ -485,8 +767,21 @@ export function resetHome(from: number): ResetOutcome {
485
767
  }
486
768
  }
487
769
  const policyConfig = writeCarriedPolicyConfig(pendingPolicyConfig);
488
- // After the deletions, onto the fresh config, so the upgrade cannot revoke a
770
+ // AFTER the carry, so a layout-2 value that happens to be named `collector`
771
+ // is retired too rather than surviving because it arrived a moment later.
772
+ retirePolicyConfigKeys();
773
+ // After the deletions, onto the fresh files, so the upgrade cannot revoke a
489
774
  // choice the user made.
775
+ //
776
+ // The credentials go first and unconditionally: `daemon.configured` below makes
777
+ // the machine fail closed, and a machine that fails closed against a fleet it
778
+ // can no longer authenticate to is worse than either half alone.
779
+ if (pendingCredentials) writeCredentials(pendingCredentials);
780
+ if (pendingConfig) writeConfig(pendingConfig);
781
+ // AFTER the config carry, or a carried `telemetry.enabled: true` — the default,
782
+ // which is not a choice anyone made — would overwrite the opt-out this reads
783
+ // straight out of the same file. Both paths end at the same key, and only one
784
+ // of them represents something a person typed.
490
785
  if (telemetryOptOut) updateConfig({ telemetry: { enabled: false } });
491
786
  writeVersionFile();
492
787
  return { removed, migrated, activity, policyConfig, from };
@@ -499,15 +794,32 @@ export interface LayoutCheck {
499
794
  /** True when the caller should stop rather than continue. */
500
795
  fatal: boolean;
501
796
  /**
502
- * True when this call actually reset the home.
797
+ * True when this call actually migrated the home.
798
+ *
799
+ * **Reporting only. The caller must NOT force setup on it.**
503
800
  *
504
- * The caller must force setup when it is set. A reset removes the global
505
- * policy config, but `isConfigured()` is a union that also counts the agent
506
- * CLIs' settings files — which the reset deliberately leaves alone — so the
507
- * machine still reads as configured, the wizard is skipped, and
508
- * `markLauncherSeen()` back-fills the marker so every later run skips it too.
509
- * The user is left with hooks firing on every tool call against no policies
510
- * at all, and nothing ever says so again.
801
+ * It used to mean "force the wizard", and the reason was a real gap: the reset
802
+ * removed the global policy config while `isConfigured()` is a union that also
803
+ * counts the agent CLIs' settings files — which the reset deliberately leaves
804
+ * alone — so the machine read as configured, the wizard was skipped, and
805
+ * `markLauncherSeen()` back-filled the marker so every later run skipped it
806
+ * too. The user was left with hooks firing on every tool call against no
807
+ * policies at all, and nothing ever said so again.
808
+ *
809
+ * That gap is closed at the source. `policies-config.json`, `config.json` and
810
+ * `credentials.json` are `user-typed` in `HOME_CLASSES` and are no longer
811
+ * removed, so after a migration `isConfigured()` is true because the machine
812
+ * genuinely IS configured — its policy selection, its `daemon.configured`
813
+ * flag and its cloud enrolment all survived. Everything the migration still
814
+ * drops (audit cache, cloud deployments, daemon scratch) is re-derived or
815
+ * re-fetched with nobody present.
816
+ *
817
+ * Forcing setup from here would now mean opening an interactive wizard for a
818
+ * machine with nothing to answer — and on the machines that matter most (a
819
+ * fleet box, a CI runner, a headless gateway) there is nobody to answer it. A
820
+ * home that genuinely never finished setup still reaches the wizard by the
821
+ * ordinary route: `isConfigured()` is false for it, so `shouldOfferFirstRun`
822
+ * fires on its own. That is why forcing was redundant even before it was wrong.
511
823
  */
512
824
  didReset: boolean;
513
825
  }
@@ -520,6 +832,61 @@ export interface LayoutCheck {
520
832
  * written by a NEWER CLI holds data this build cannot read but a simple
521
833
  * upgrade could, and deleting it would destroy something recoverable.
522
834
  */
835
+ /**
836
+ * Deliver what is already spooled, immediately AFTER the migration.
837
+ *
838
+ * The order is the whole subtlety here, and the obvious one is wrong. Flushing
839
+ * FIRST reads intuitive — get the events out before touching the disk — and it
840
+ * cannot work: everything a flush needs to run is in a file the stale layout
841
+ * hasn't got. `readConfig()` reads `config.json` and `readIngestCredential()`
842
+ * reads `credentials.json`, both of which are LAYOUT-3 files, and a stale home by
843
+ * definition has neither. Called before the migration, the flush would find no
844
+ * ingest credential on every machine it ever ran on, refuse, and report nothing
845
+ * pending — a step that looks like it protects data and is structurally incapable
846
+ * of doing anything at all.
847
+ *
848
+ * Afterwards, both files exist: `readCarriedLegacyCredentials()` has just put the
849
+ * token back and `readCarriedLegacyConfig()` the mode. So this runs where it can
850
+ * actually succeed.
851
+ *
852
+ * What makes that safe rather than a gamble is that this is NOT the thing
853
+ * protecting the events. `HOME_CLASSES` classes the spool `undelivered`, so it
854
+ * survives the migration whatever happens here — losing it would be permanent,
855
+ * because `cursors/` survives too and the watermark has already advanced past
856
+ * every batch in it. This is the difference between "delivered" and "delivered on
857
+ * the next collector pass", which matters only because the collector is unhurried
858
+ * on purpose (a batch is swept once it is older than two minutes, at most 64 per
859
+ * pass, on a 60-second cadence) and somebody standing at a dashboard cannot tell
860
+ * "not yet" from "not working".
861
+ *
862
+ * BEST-EFFORT BY CONSTRUCTION. `runFlushCommand` refuses, with its own message
863
+ * and a non-zero code, on every machine where a flush cannot work: collection
864
+ * off, no ingest credential, an unsupported platform, no daemon listening. All of
865
+ * those are ordinary here rather than errors, so the result is read for its COUNT
866
+ * and its exit code discarded. Bounded at 30s: this sits in front of a command
867
+ * the user typed, and a spool that will not drain is not a reason to hold their
868
+ * terminal.
869
+ */
870
+ async function drainSpoolAfterMigrating(): Promise<number> {
871
+ try {
872
+ // Cheap gates first, so an OSS machine and one that never connected cost
873
+ // nothing at all — no dynamic import, no daemon probe. Read AFTER the
874
+ // migration, so these see the carried values rather than the defaults a
875
+ // missing layout-3 file would have produced.
876
+ const cfg = readConfig();
877
+ if (cfg.mode !== "cloud") return 0;
878
+ if (!cfg.collector.hooks && !cfg.collector.sessions) return 0;
879
+ const { runFlushCommand } = await import("./flush-cli");
880
+ const result = await runFlushCommand({ wait: true, timeoutSecs: 30 });
881
+ return result.pending;
882
+ } catch {
883
+ // A flush that throws is not a migration failure. Reported as "nothing
884
+ // pending" because the number only adds a line to a message, and inventing a
885
+ // count from a failed probe would be worse than saying nothing.
886
+ return 0;
887
+ }
888
+ }
889
+
523
890
  export async function checkLayoutForCli(): Promise<LayoutCheck> {
524
891
  const state = detectLayout();
525
892
 
@@ -539,7 +906,18 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
539
906
  }
540
907
 
541
908
  if (state.kind === "stale") {
542
- const { removed, migrated, activity } = resetHome(state.found);
909
+ // Through the registry, not `resetHome` directly. The two look identical for
910
+ // today's layouts — the chain from 1 or 2 is one step, and that step IS
911
+ // `resetHome` — and they stop being identical the moment a layout 4 exists,
912
+ // at which point this call site needs no change. It also means every
913
+ // migration this machine has ever run is recorded, and the irreplaceable
914
+ // files are copied aside first. See `migrations.ts`.
915
+ const { runMigrations } = await import("./migrations");
916
+ const run = runMigrations(state.found);
917
+ const { removed, migrated, activity } = run.outcome;
918
+ // After, not before — see the function's own note for why the intuitive
919
+ // order cannot work.
920
+ const pending = await drainSpoolAfterMigrating();
543
921
  return {
544
922
  state,
545
923
  fatal: false,
@@ -547,11 +925,15 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
547
925
  lines: [
548
926
  `failproofai reorganised ${failproofaiHome()} in this version.`,
549
927
  // "activity history" was in this sentence while the reset was deleting
550
- // it. It is carried over now, so the message says what was KEPT as well
551
- // as what went — a user who reads "removed" and nothing else has no way
552
- // to know their decision log survived.
553
- `Removed ${removed.length} item(s) from the old layout — policy config and`,
554
- `audit cache. Your daemon binary and decision history were kept.`,
928
+ // it, and "policy config" stayed in it after that file stopped being
929
+ // removed. Both are the same failure: a message describing a delete list
930
+ // it is not derived from. It names the CLASSES now, which is what
931
+ // `HOME_CLASSES` actually decides — so it cannot drift again without the
932
+ // rule itself changing.
933
+ `Removed ${removed.length} item(s) that this version rebuilds — the audit`,
934
+ `cache, cloud deployments (re-fetched on the next poll) and daemon scratch.`,
935
+ `Your settings, cloud enrolment, policy selection, decision history,`,
936
+ `undelivered events and daemon binary were all kept.`,
555
937
  // Named individually rather than counted. These are files a person
556
938
  // wrote; "moved 3 items" is not something you can check at a glance,
557
939
  // and the whole point of saying it is that they can.
@@ -573,8 +955,35 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
573
955
  `Carried ${activity.length} page(s) of decision history into ${hookActivityDir()}.`,
574
956
  ]
575
957
  : []),
576
- ``,
577
- `Run \`failproofai config\` to set up again.`,
958
+ // Said only when a backlog actually survived the flush. Silence here
959
+ // would be the wrong kind: the events are safe, but "safe" and
960
+ // "delivered" are different states and only one of them shows up on a
961
+ // dashboard. Naming the count is what stops a user reading an incomplete
962
+ // dashboard as data loss.
963
+ ...(pending > 0
964
+ ? [
965
+ ``,
966
+ `${pending} batch(es) were still undelivered and were carried across.`,
967
+ `They ship on the next collector pass — \`failproofai flush --wait\` now if you`,
968
+ `are waiting on a dashboard.`,
969
+ ]
970
+ : []),
971
+ // A step that threw leaves the home marked with the OLD layout, so the
972
+ // next command tries again — which is right, and is also why this must
973
+ // say so rather than let a partial migration pass for a finished one.
974
+ ...(run.failed
975
+ ? [
976
+ ``,
977
+ `Step ${run.failed.from} → ${run.failed.to} did not finish: ${run.failed.error}`,
978
+ `The home is still marked layout ${state.found} and will be retried. Copies of`,
979
+ `your settings and enrolment were saved first, in ${migrationBackupDir(state.found)}.`,
980
+ ]
981
+ : []),
982
+ // No "run `failproofai config` to set up again". There is nothing to set
983
+ // up: the settings, the enrolment and the policy selection all survived,
984
+ // so the machine enforces exactly as it did before this command ran. A
985
+ // home that genuinely never finished setup reaches the wizard through
986
+ // `shouldOfferFirstRun`, which reads `isConfigured()` — see `didReset`.
578
987
  ],
579
988
  };
580
989
  }