failproofai 1.0.0-beta.12 → 1.0.0-beta.13

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 (173) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  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/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -3
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +2 -3
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  31. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -2
  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 +2 -2
  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 +2 -2
  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 +2 -2
  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 +2 -2
  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/server-reference-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/audit/page.js +2 -3
  46. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.rsc +15 -15
  50. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  52. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  53. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  54. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  55. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  56. package/.next/standalone/.next/server/app/page.js +2 -3
  57. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  60. package/.next/standalone/.next/server/app/policies/page.js +2 -3
  61. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/page.js +3 -4
  65. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +3 -4
  70. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js +3 -4
  74. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  76. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  77. package/.next/standalone/.next/server/app/settings/page.js +2 -3
  78. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1a7i2q4._.js → [root-of-the-server]__00ni9za._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1_ps0jw._.js → [root-of-the-server]__0effm9m._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0lk_935._.js → [root-of-the-server]__0ix8gm7._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0ky_n0r._.js → [root-of-the-server]__0ji11a-._.js} +1 -1
  88. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0v2fpxe._.js → [root-of-the-server]__0ojwjxl._.js} +1 -1
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1uyc-z4._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -3
  95. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -3
  96. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1rdruw4._.js → [root-of-the-server]__0_1tiya._.js} +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ha3gul._.js → [root-of-the-server]__0eeipso._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +3 -3
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1tyf0tc._.js → [root-of-the-server]__17itmx-._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1io8n-9._.js → [root-of-the-server]__1dx-a-c._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +5 -0
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1yw368c._.js → [root-of-the-server]__1n2vcv4._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1a2hcb_._.js → [root-of-the-server]__1tlvv9a._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{_1fahg9e._.js → _0vo2qyt._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/{_0uz7o9y._.js → _0xej-0e._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/ssr/{_04mekdy._.js → _10399ge._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/{_0se484m._.js → _149k9oy._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/{_1vsgpqt._.js → _166jvu8._.js} +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/{_0r-dsre._.js → _1_hwp06._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/{_1w8sola._.js → _1mnhi_r._.js} +2 -2
  114. package/.next/standalone/.next/server/chunks/ssr/{_1w_6p9g._.js → _1ror7k9._.js} +2 -2
  115. package/.next/standalone/.next/server/chunks/ssr/{_1gmzkwj._.js → _1sdgprl._.js} +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  119. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  120. package/.next/standalone/.next/server/pages/404.html +1 -1
  121. package/.next/standalone/.next/server/pages/500.html +1 -1
  122. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  123. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  124. package/.next/standalone/.next/static/chunks/{2_h--2ame3r83.js → 01s3vpzky_qew.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  126. package/.next/standalone/.next/static/chunks/{23hp7hekegpe5.js → 0zbcd_n8ku2ov.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/{0cakk77ppmlo7.js → 1erji0ratofla.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{40-zk44qsg5y-.js → 23hnlzqx44vbe.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{1wb8cnmzx69rz.js → 2cwsiyhc7d2cw.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{0_rdbv_k5j51p.js → 2y42ebzynyxdv.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{1spk1bub0vd-h.js → 315lncj7dsp9c.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{0-zh1umoq6ppc.js → 31_px0dfgdg9j.js} +2 -2
  133. package/.next/standalone/.next/static/chunks/{2bie7ajv70p9b.js → 3l4-fu3xt75j8.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{298v78ypf8edl.js → 3poscoxn69x9g.js} +1 -1
  135. package/.next/standalone/app/policies/hooks-client.tsx +4 -4
  136. package/.next/standalone/ino.ujh3/.failproofai/VERSION +4 -0
  137. package/.next/standalone/ino.ujh3/.failproofai/state/last-version +1 -0
  138. package/.next/standalone/ino.ujh3/.failproofai/state/telemetry-id +1 -0
  139. package/.next/standalone/package.json +10 -10
  140. package/.next/standalone/server.js +1 -1
  141. package/bin/failproofai.mjs +186 -5
  142. package/dist/cli.mjs +1130 -1352
  143. package/dist/worker.mjs +107 -1000
  144. package/package.json +10 -10
  145. package/src/hooks/backfill-cli.ts +1 -1
  146. package/src/hooks/cloud-connection.ts +4 -4
  147. package/src/hooks/cloud-enrollment-cli.ts +1 -1
  148. package/src/hooks/cloud-enrollment.ts +3 -3
  149. package/src/hooks/cloud-managed-policies.ts +60 -22
  150. package/src/hooks/configure-wizard.ts +124 -53
  151. package/src/hooks/custom-hooks-loader.ts +57 -8
  152. package/src/hooks/daemon-service.ts +66 -11
  153. package/src/hooks/flush-cli.ts +180 -0
  154. package/src/hooks/fp-config.ts +242 -118
  155. package/src/hooks/fp-home.ts +138 -22
  156. package/src/hooks/fp-reset.ts +357 -43
  157. package/src/hooks/handler.ts +11 -11
  158. package/src/hooks/harness-cli.ts +309 -0
  159. package/src/hooks/hook-activity-store.ts +5 -5
  160. package/src/hooks/manager.ts +35 -0
  161. package/src/hooks/onboarding-attempt.ts +22 -6
  162. package/src/hooks/tui.ts +64 -7
  163. package/src/hooks/uninstall-cli.ts +1 -1
  164. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +0 -3
  165. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1iny24d._.js +0 -3
  166. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tyedjt._.js +0 -3
  167. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +0 -5
  168. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +0 -4
  169. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +0 -5
  170. package/.next/standalone/.next/static/chunks/13vrxdsdihqd_.css +0 -2
  171. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_buildManifest.js +0 -0
  172. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_clientMiddlewareManifest.js +0 -0
  173. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_ssgManifest.js +0 -0
@@ -29,12 +29,24 @@
29
29
  * user out of their agent entirely, with no way back except hand-editing JSON.
30
30
  * A loud warning that survives until setup runs is the proportionate answer.
31
31
  */
32
- import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
33
- import { resolve } from "node:path";
32
+ import {
33
+ copyFileSync,
34
+ existsSync,
35
+ mkdirSync,
36
+ readFileSync,
37
+ readdirSync,
38
+ renameSync,
39
+ rmSync,
40
+ rmdirSync,
41
+ statSync,
42
+ writeFileSync,
43
+ } from "node:fs";
44
+ import { dirname, resolve } from "node:path";
34
45
  import {
35
46
  LAYOUT_VERSION,
36
47
  customPoliciesDir,
37
48
  failproofaiHome,
49
+ globalPolicyConfigFile,
38
50
  hookActivityDir,
39
51
  legacy,
40
52
  policiesDir,
@@ -56,60 +68,123 @@ export interface ResetOutcome {
56
68
  migrated: string[];
57
69
  /** Basenames of decision-log pages carried into layout 2's `hook-activity/`. */
58
70
  activity: string[];
71
+ /** Keys of the layout-1 policy config carried into layout 2's. */
72
+ policyConfig: string[];
59
73
  /** The layout that was found before the reset. */
60
74
  from: number;
61
75
  }
62
76
 
63
77
  /** Script files a person could have written by hand. `.d.ts` is a type stub. */
64
- const USER_SOURCE_RE = /\.(js|mjs|ts)$/;
65
-
66
78
  /**
67
- * Move hand-written policies from layout 1's `policies/` into layout 2's
68
- * `policies/custom-policies/`, where the loader actually reads them.
69
- *
70
- * Layout 1 documented `~/.failproofai/policies/` as the user scope for personal
71
- * convention policies, and layout 2 moved the directory the loader opens down a
72
- * level (`customPoliciesDir()`). Left alone, those files survive the reset —
73
- * that part is handled by keeping the parent out of `resettablePaths()` — but
74
- * nothing would ever load them again, which is the same silent enforcement gap
75
- * by a slower route.
76
- *
77
- * Every loadable extension moves, not just the `*policies.{js,mjs,ts}` names
78
- * the convention loads: a file that misses the naming convention is still
79
- * source somebody wrote, and `findSkippedPolicyFiles` exists precisely to tell
80
- * them so. Leaving it behind to be reported against an empty directory helps
81
- * nobody.
82
- *
83
- * A destination that already exists is never overwritten — the source is left
84
- * in place instead. The parent directory is no longer deleted, so "left in
85
- * place" means "still there to look at", which is the right outcome for the
86
- * only case that reaches it (a half-finished earlier migration).
79
+ * Move the user's own policy directory back up: layout 2's
80
+ * `policies/custom-policies/` into layout 3's `policies/`, where the loader
81
+ * reads it.
82
+ *
83
+ * Left alone, nothing would ever load those files again — and worse, the reset
84
+ * DELETES `custom-policies/` (it is in `resettablePaths()`), so they would not
85
+ * merely stop loading, they would be gone.
86
+ *
87
+ * EVERY entry moves — every file regardless of extension, and every
88
+ * subdirectory — not just the loadable sources. Moving only `*.{js,mjs,ts}`
89
+ * deleted the rest along with the directory, and the two things it deleted are
90
+ * exactly the things a real policy depends on: a `lib/` of shared helpers the
91
+ * policy imports, and the `.json` data file it reads its rules from. The
92
+ * surviving policy then referenced a `./lib/rules.mjs` that no longer existed,
93
+ * so the migration turned a working policy into a broken one AND destroyed
94
+ * source nothing regenerates. Reproduced exactly that way on a seeded home.
95
+ *
96
+ * Moving the whole directory is also what keeps relative imports valid: every
97
+ * entry keeps its position relative to every other, so `./lib/rules.mjs`
98
+ * resolves after the move for the same reason it did before.
99
+ *
100
+ * A destination that already exists is never overwritten. Two things make that
101
+ * safe rather than lossy, and BOTH are required:
102
+ *
103
+ * - DIRECTORIES MERGE. A colliding directory is not a conflict — `lib/` on both
104
+ * sides usually holds different files, and the likeliest collision of all is
105
+ * exactly `lib/`, because layout 1 → 2 left one behind (it moved only
106
+ * `*.{js,mjs,ts}`, which is the bug this function now fixes). Skipping the
107
+ * whole directory on a name match discarded every file inside it. Only a
108
+ * genuine leaf collision — the same name, and at least one side a file — is
109
+ * left alone.
110
+ * - WHAT CANNOT MOVE IS NOT DELETED. `custom-policies/` is deliberately NOT in
111
+ * `resettablePaths()`; this function removes it itself, and only once it is
112
+ * empty. It used to be on that list, so every entry deliberately "left in
113
+ * place" was deleted seconds later by the same `resetHome()` call that had
114
+ * just decided to preserve it — the user lost hand-written source, and the
115
+ * policy that DID move was left importing a `./lib/rules.mjs` that no longer
116
+ * existed. Verified on a seeded home: the helper was gone from the machine
117
+ * entirely and the surviving policy failed to load.
87
118
  */
88
119
  export function migrateConventionPolicies(): string[] {
89
- const from = policiesDir();
120
+ // LAYOUT 3 REVERSED THIS. Layout 2 moved the user's `*.mjs` DOWN into
121
+ // `policies/custom-policies/`; layout 3 reads them straight out of
122
+ // `policies/`, which is where layout 1 kept them all along. So:
123
+ //
124
+ // • from layout 1 — nothing to do. The files are already in the directory
125
+ // that now loads them, and moving them anywhere would be a regression.
126
+ // • from layout 2 — move them back UP, out of `custom-policies/`, or the
127
+ // reset deletes that directory (it is in `resettablePaths()`) and takes
128
+ // the user's own policies with it.
129
+ //
130
+ // `resetHome` calls this BEFORE it deletes anything, which is what makes the
131
+ // second case safe rather than a race.
132
+ const from = legacy.customPoliciesDir();
90
133
  if (!existsSync(from)) return [];
91
134
  const moved: string[] = [];
92
- try {
93
- const entries = readdirSync(from, { withFileTypes: true }).filter(
94
- (e) => e.isFile() && USER_SOURCE_RE.test(e.name) && !e.name.endsWith(".d.ts"),
95
- );
96
- if (entries.length === 0) return [];
97
135
 
98
- const to = customPoliciesDir();
99
- mkdirSync(to, { recursive: true });
136
+ // Returns the names it moved, relative to `dir`. Recurses only where both
137
+ // sides are directories; everything else either moves whole or stays put.
138
+ const mergeInto = (dir: string, dest: string, prefix: string): void => {
139
+ let entries;
140
+ try {
141
+ // No filter: this whole directory is the user's — policy sources, the
142
+ // helpers they import, and the data files they read.
143
+ entries = readdirSync(dir, { withFileTypes: true });
144
+ } catch {
145
+ // A directory we cannot read is not worth aborting a reset over, and its
146
+ // contents stay where they are rather than being lost.
147
+ return;
148
+ }
149
+ if (entries.length === 0) return;
150
+ try {
151
+ mkdirSync(dest, { recursive: true });
152
+ } catch {
153
+ return;
154
+ }
100
155
  for (const entry of entries) {
101
- const target = resolve(to, entry.name);
102
- if (existsSync(target)) continue;
103
- try {
104
- renameSync(resolve(from, entry.name), target);
105
- moved.push(entry.name);
106
- } catch {
107
- // One unmovable file must not strand the rest. It stays where it is,
108
- // and the reset no longer deletes that directory.
156
+ const source = resolve(dir, entry.name);
157
+ const target = resolve(dest, entry.name);
158
+ const name = `${prefix}${entry.name}`;
159
+ if (!existsSync(target)) {
160
+ try {
161
+ renameSync(source, target);
162
+ moved.push(name);
163
+ } catch {
164
+ // One unmovable entry must not strand the rest — and because this
165
+ // directory is no longer on the reset list, "not moved" now really
166
+ // does mean "still on disk".
167
+ }
168
+ continue;
169
+ }
170
+ // Both directories: merge their contents rather than discarding the
171
+ // source wholesale. Anything genuinely colliding inside is left alone by
172
+ // the same rule, one level down.
173
+ if (entry.isDirectory() && statSync(target).isDirectory()) {
174
+ mergeInto(source, target, `${name}/`);
109
175
  }
110
176
  }
177
+ };
178
+
179
+ mergeInto(from, policiesDir(), "");
180
+
181
+ // Remove the directory ONLY if the merge emptied it. A non-empty
182
+ // `custom-policies/` is the user's remaining files, and this is the one place
183
+ // that decides their fate — `resettablePaths()` no longer lists it.
184
+ try {
185
+ rmdirSync(from);
111
186
  } catch {
112
- // A policies directory we cannot read is not worth aborting a reset over.
187
+ // Not empty, or not removable. Either way it stays, which is the point.
113
188
  }
114
189
  return moved.sort((a, b) => a.localeCompare(b));
115
190
  }
@@ -202,6 +277,126 @@ export function migrateHookActivity(): string[] {
202
277
  return moved.sort((a, b) => a.localeCompare(b));
203
278
  }
204
279
 
280
+ /**
281
+ * Fields of layout 1's `policies-config.json` that layout 2's still means.
282
+ *
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
286
+ * `fpai-collect`'s `Settings` deserializes them and camelCase keys would make
287
+ * every field silently fall back to its default (see the note on `Settings` in
288
+ * `crates/fpai-collect/src/config.rs`, which records that exact bug). Carrying
289
+ * `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.
294
+ */
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
+
306
+ /**
307
+ * Carry the user's policy SELECTION across the layout-1 → layout-2 move.
308
+ *
309
+ * Layout 1 kept it at `~/.failproofai/policies-config.json`; layout 2 keeps it
310
+ * at `policies/local-policies/policies-config.json`, and both were on the reset
311
+ * list. So an upgrade silently emptied `enabledPolicies` — every builtin the
312
+ * user had turned on, every explicit `customPoliciesPaths` entry, and every
313
+ * per-policy parameter. The machine still read as configured afterwards
314
+ * (`isConfigured()` is a union that sees the agent CLIs' untouched settings
315
+ * files), so hooks kept firing against a policy set that had quietly become
316
+ * the default one. That is the same silent enforcement gap
317
+ * `migrateConventionPolicies()` exists to close, by a different route.
318
+ *
319
+ * This is deliberately NARROW. The standing decision for layout 1 is
320
+ * wipe-and-re-setup rather than migrate, because a half-migrated home that
321
+ * reads "no data" is worse than one that says so — see `legacy` in
322
+ * `fp-home.ts`. Everything derived (cursors, spool, health, audit cache) still
323
+ * goes and is rebuilt. What is carried is only what a person typed and nothing
324
+ * regenerates, which is the same test `migrateHookActivity()` applies to the
325
+ * decision log.
326
+ *
327
+ * # Two phases, because the source and the destination are BOTH on the list
328
+ *
329
+ * Unlike the two migrations above, this one cannot run entirely before the
330
+ * deletions. Its source (`legacy.policyConfig()`) is removed by them, and so is
331
+ * its destination (`globalPolicyConfigFile()`) — rightly, since clearing a
332
+ * stale selection on a layout migration is the documented behaviour. Writing
333
+ * first would have the reset delete the carry moments after it happened, which
334
+ * is exactly what the note on `hookActivityDir()` in `resettablePaths()` records
335
+ * happening once already.
336
+ *
337
+ * So: READ before, WRITE after.
338
+ */
339
+ 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:
343
+ //
344
+ // layout 2 — `policies/local-policies/policies-config.json`
345
+ // layout 1 — `policies-config.json` at the home root
346
+ //
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();
353
+ if (!existsSync(from)) return null;
354
+
355
+ let parsed: Record<string, unknown>;
356
+ try {
357
+ parsed = JSON.parse(readFileSync(from, "utf8")) as Record<string, unknown>;
358
+ } catch {
359
+ // Unparseable is not worth aborting a reset over, and there is nothing to
360
+ // carry. The file is removed with the rest of layout 1, which is also what
361
+ // would have happened before this function existed.
362
+ return null;
363
+ }
364
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
365
+
366
+ 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
+ }
373
+ }
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;
377
+
378
+ // `enabledPolicies` is required by the type and by every reader. A layout-1
379
+ // file that somehow lacked it would otherwise produce a layout-2 file that
380
+ // throws on read — worse than the empty default it replaces.
381
+ if (carried.enabledPolicies === undefined) carried.enabledPolicies = [];
382
+ return carried;
383
+ }
384
+
385
+ /** Second phase of {@link readCarriedPolicyConfig}; runs AFTER the deletions. */
386
+ export function writeCarriedPolicyConfig(carried: Record<string, unknown> | null): string[] {
387
+ if (!carried) return [];
388
+ const to = globalPolicyConfigFile();
389
+ try {
390
+ mkdirSync(dirname(to), { recursive: true });
391
+ writeFileSync(to, JSON.stringify(carried, null, 2) + "\n", "utf8");
392
+ } catch {
393
+ // A destination we cannot write is not worth aborting the reset over; the
394
+ // user re-runs setup, which is the pre-existing behaviour.
395
+ return [];
396
+ }
397
+ return Object.keys(carried).sort((a, b) => a.localeCompare(b));
398
+ }
399
+
205
400
  /**
206
401
  * Delete every path layout 1 or layout 2 could have written, then stamp
207
402
  * VERSION so the next run reads as current.
@@ -212,13 +407,73 @@ export function migrateHookActivity(): string[] {
212
407
  * large, version-pinned and re-verified on use, and `run/` holds sockets
213
408
  * belonging to a process that may be alive right now.
214
409
  */
410
+ /**
411
+ * Carry a telemetry OPT-OUT across a layout-2 upgrade.
412
+ *
413
+ * Layout 2 kept it in `config.toml`, which the reset deletes, and layout 3
414
+ * writes a fresh `config.json` whose default is `telemetry.enabled: true`. So an
415
+ * upgrade silently turned anonymous telemetry back ON for anyone who had turned
416
+ * it off — and that file is the ONLY off-switch that reaches the daemon, because
417
+ * a system-scope service unit does not inherit `FAILPROOFAI_TELEMETRY_DISABLED`
418
+ * from anyone's shell. An opt-out that a routine upgrade revokes is not an
419
+ * opt-out.
420
+ *
421
+ * Deliberately one-directional: only `false` is carried. Everything else in that
422
+ * file is either re-derived by setup or a thing the wizard re-asks, and carrying
423
+ * an ENABLED flag forward would be carrying the default, which is not a choice
424
+ * anyone made. This matches the narrow rule the policy-config carry follows —
425
+ * move only what a person typed and nothing regenerates.
426
+ *
427
+ * A regex rather than a TOML parser, for the reason `readLegacyTomlVersion`
428
+ * gives: the dependency is gone, and this is a flat `key = value` file this
429
+ * codebase wrote. Read BEFORE the deletions, applied after — same two-phase
430
+ * shape, same reason.
431
+ */
432
+ export function readCarriedTelemetryOptOut(): boolean {
433
+ const from = legacy.configToml();
434
+ if (!existsSync(from)) return false;
435
+ try {
436
+ const text = readFileSync(from, "utf8");
437
+ // Scoped to the `[telemetry]` table, not the whole file — a stray
438
+ // `enabled = false` under `[collector]` is not a telemetry opt-out.
439
+ //
440
+ // Sliced rather than matched with a single expression: the obvious form ends
441
+ // the section with `(?=^\s*\[|\Z)`, and JavaScript HAS NO `\Z`. It parses as
442
+ // a literal "Z", so the lookahead only ever succeeded via its other branch —
443
+ // meaning the section was found when another table followed it and MISSED
444
+ // whenever `[telemetry]` was last in the file, which is exactly where a
445
+ // hand-added opt-out tends to be. Caught by the test for this function; the
446
+ // live fixture happened to have `[collector]` after it and passed.
447
+ const start = text.search(/^\s*\[telemetry\]\s*$/m);
448
+ if (start === -1) return false;
449
+ const body = text.slice(start).replace(/^\s*\[telemetry\]\s*$/m, "");
450
+ const nextTable = body.search(/^\s*\[/m);
451
+ const section = nextTable === -1 ? body : body.slice(0, nextTable);
452
+ return /^\s*enabled\s*=\s*false\s*$/m.test(section);
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
215
458
  export function resetHome(from: number): ResetOutcome {
216
459
  // BEFORE the deletions, so a file that is mid-move is never one the reset
217
460
  // then walks over.
218
461
  const migrated = migrateConventionPolicies();
219
462
  const activity = migrateHookActivity();
463
+ 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.
466
+ const telemetryOptOut = readCarriedTelemetryOptOut();
220
467
  const removed: string[] = [];
221
468
  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;
222
477
  if (!existsSync(path)) continue;
223
478
  try {
224
479
  rmSync(path, { recursive: true, force: true });
@@ -229,8 +484,12 @@ export function resetHome(from: number): ResetOutcome {
229
484
  // directory from an old layout is inert once nothing reads it.
230
485
  }
231
486
  }
487
+ const policyConfig = writeCarriedPolicyConfig(pendingPolicyConfig);
488
+ // After the deletions, onto the fresh config, so the upgrade cannot revoke a
489
+ // choice the user made.
490
+ if (telemetryOptOut) updateConfig({ telemetry: { enabled: false } });
232
491
  writeVersionFile();
233
- return { removed, migrated, activity, from };
492
+ return { removed, migrated, activity, policyConfig, from };
234
493
  }
235
494
 
236
495
  export interface LayoutCheck {
@@ -320,7 +579,15 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
320
579
  };
321
580
  }
322
581
 
323
- if (state.kind === "absent") writeVersionFile();
582
+ // Write the marker back for a fresh home — and ALSO for one whose layout had
583
+ // to be recovered from a landmark. `inferred` means the home is genuinely on
584
+ // this layout and only `VERSION` is missing; leaving it missing means every
585
+ // later command re-infers it, and `writeVersionFile()` carries the daemon
586
+ // version forward from the file it just failed to read, so the recorded
587
+ // daemon version is erased and `daemonVersionSkew()` goes quiet about a stale
588
+ // daemon. Re-stamping is what "the only thing actually missing is the marker"
589
+ // was supposed to mean.
590
+ if (state.kind === "absent" || (state.kind === "current" && state.inferred)) writeVersionFile();
324
591
  return {
325
592
  state,
326
593
  fatal: false,
@@ -447,6 +714,40 @@ function staleDaemonHint(): string[] {
447
714
  * machine whose global policies have silently stopped firing) persists until
448
715
  * somebody acts on it.
449
716
  */
717
+ /**
718
+ * Why an UNATTENDED run must not proceed — a different question from the hook's.
719
+ *
720
+ * The hook asks "are global policies unenforced?", and answers it about one
721
+ * file. This asks "is this home on a layout this build understands?", and the
722
+ * answer must not be softened by anything: the scheduled audit runs with nobody
723
+ * watching, and the ordinary CLI path RESETS a stale home — which deletes
724
+ * `config.toml` (revoking a telemetry opt-out and the `[audit] auto` flag that
725
+ * scheduled the run) and `credentials.toml` (dropping cloud enrolment). Doing
726
+ * that on a timer, with the explanation going only to the service journal, is
727
+ * the failure this gate exists to prevent.
728
+ *
729
+ * Sharing `layoutWarningForHook()` for both is what made this subtle: teaching
730
+ * that function to stay quiet when the global policy config IS readable — right
731
+ * for the hook, since layout 1 and layout 3 keep it at the same path — silently
732
+ * turned this gate off for exactly the homes most likely to hit it.
733
+ */
734
+ export function layoutBlockerForScheduledRun(): string | null {
735
+ const state = detectLayout();
736
+ if (state.kind === "current" || state.kind === "absent") return null;
737
+ if (state.kind === "future") {
738
+ return (
739
+ `[failproofai] this directory was written by a newer version ` +
740
+ `(layout ${state.found} vs ${LAYOUT_VERSION}) — refusing to run unattended. ` +
741
+ `Upgrade failproofai.`
742
+ );
743
+ }
744
+ return (
745
+ `[failproofai] setup predates this version (layout ${state.found} vs ${LAYOUT_VERSION}) — ` +
746
+ `refusing to run unattended, because completing it would reset this home. ` +
747
+ `Run \`failproofai config\`.`
748
+ );
749
+ }
750
+
450
751
  export function layoutWarningForHook(): string | null {
451
752
  const state = detectLayout();
452
753
  if (state.kind === "current" || state.kind === "absent") return null;
@@ -457,6 +758,19 @@ export function layoutWarningForHook(): string | null {
457
758
  `Upgrade failproofai.`
458
759
  );
459
760
  }
761
+ // The claim is about ONE FILE — the global policy config — so ask about that
762
+ // file rather than about the layout number. Layout 1 and layout 3 keep it at
763
+ // the SAME path (`~/.failproofai/policies-config.json`), so a home carrying
764
+ // one is read as layout 1 by the landmark fallback while the hook path loads
765
+ // and enforces it perfectly well. Hand-writing that file is something
766
+ // `docs/configuration.mdx` explicitly tells people to do, and they were told
767
+ // on every single tool call that their policies were not being enforced
768
+ // WHILE THEY WERE — the deny and the warning printed together.
769
+ //
770
+ // A warning that contradicts the behaviour it describes is worse than no
771
+ // warning: it teaches people to ignore the one channel that will matter when
772
+ // enforcement really has stopped.
773
+ if (existsSync(globalPolicyConfigFile())) return null;
460
774
  return (
461
775
  `[failproofai] setup predates this version — global policies are NOT being enforced. ` +
462
776
  `Run \`failproofai config\` to re-create it.`
@@ -254,13 +254,13 @@ export async function evaluateHookEvent(
254
254
  /** Registered policy name → where it came from. See the set() below. */
255
255
  const policyAttribution = new Map<
256
256
  string,
257
- { source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudRevision?: number }
257
+ { source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudVersion?: number }
258
258
  >();
259
- let cloudGeneration: number | undefined;
259
+ let cloudDeployment: number | undefined;
260
260
  /** What observe-mode policies WOULD have done, had they been enforcing. */
261
261
  const observedResults: Array<{
262
262
  policyId: string;
263
- revision: number;
263
+ version: number;
264
264
  decision: "deny" | "instruct";
265
265
  reason: string | null;
266
266
  }> = [];
@@ -293,7 +293,7 @@ export async function evaluateHookEvent(
293
293
 
294
294
  // Cloud-managed policies are daemon-reconciled artifacts, but they use
295
295
  // the same public JS policy API as local custom policies. Verify and add
296
- // only the paths referenced by the atomically active generation.
296
+ // only the paths referenced by the atomically active deployment.
297
297
  //
298
298
  // Wrapped, like `readConfigAt` and `readActivePause` above it. This call
299
299
  // has fourteen throw sites — a malformed manifest, an unsafe id, an
@@ -332,7 +332,7 @@ export async function evaluateHookEvent(
332
332
  // policy decided this event: "what was deployed here at the time" is a
333
333
  // separate question from "what decided", and only the former can tell a
334
334
  // rollout that changed nothing from one that never reached the machine.
335
- cloudGeneration = cloudManagedPolicies[0]?.generation;
335
+ cloudDeployment = cloudManagedPolicies[0]?.deployment;
336
336
  const configuredCustomPaths = config.customPoliciesPaths ?? config.customPoliciesPath;
337
337
  const allExplicitPaths =
338
338
  cloudManagedPolicies.length === 0
@@ -368,7 +368,7 @@ export async function evaluateHookEvent(
368
368
  const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
369
369
  const isConvention = !!conventionScope;
370
370
  const prefix = cloudManaged
371
- ? `cloud/${cloudManaged.id}@${cloudManaged.revision}`
371
+ ? `cloud/${cloudManaged.id}@${cloudManaged.version}`
372
372
  : isConvention
373
373
  ? `.failproofai-${conventionScope}`
374
374
  : "custom";
@@ -383,7 +383,7 @@ export async function evaluateHookEvent(
383
383
  if (shadow.decision !== "allow") {
384
384
  observedResults.push({
385
385
  policyId: cloudManaged!.id,
386
- revision: cloudManaged!.revision,
386
+ version: cloudManaged!.version,
387
387
  decision: shadow.decision,
388
388
  reason: shadow.reason ?? null,
389
389
  });
@@ -419,7 +419,7 @@ export async function evaluateHookEvent(
419
419
  // this decision", could only be answered by re-parsing our own label.
420
420
  policyAttribution.set(registeredName, {
421
421
  source: cloudManaged ? "cloud" : isConvention ? "convention" : "custom",
422
- ...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudRevision: cloudManaged.revision } : {}),
422
+ ...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudVersion: cloudManaged.version } : {}),
423
423
  });
424
424
  registerPolicy(
425
425
  registeredName,
@@ -497,13 +497,13 @@ export async function evaluateHookEvent(
497
497
  return {
498
498
  policySource: attribution?.source ?? ("builtin" as const),
499
499
  ...(attribution?.cloudPolicyId ? { cloudPolicyId: attribution.cloudPolicyId } : {}),
500
- ...(attribution?.cloudRevision !== undefined
501
- ? { cloudRevision: attribution.cloudRevision }
500
+ ...(attribution?.cloudVersion !== undefined
501
+ ? { cloudVersion: attribution.cloudVersion }
502
502
  : {}),
503
503
  };
504
504
  })()
505
505
  : {}),
506
- ...(cloudGeneration !== undefined ? { cloudGeneration } : {}),
506
+ ...(cloudDeployment !== undefined ? { cloudDeployment } : {}),
507
507
  // The point of observe mode is this record. Without it the rollout is
508
508
  // unmeasurable and the row is indistinguishable from one where the policy
509
509
  // never matched at all.