failproofai 1.0.12-beta.0 → 1.0.12-beta.2

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 (214) 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.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js +22 -22
  23. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  31. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  32. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/index.html +1 -1
  35. package/.next/standalone/.next/server/app/index.rsc +3 -3
  36. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  37. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  43. package/.next/standalone/.next/server/app/policies/page.js +4 -4
  44. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  54. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  57. package/.next/standalone/.next/server/app/settings/page.js +7 -7
  58. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/{[externals]__0-ha5ydqq0mws._.js → [externals]__0vt9i6fqinjne._.js} +1 -1
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__20xe05hm5u8_1._.js → [root-of-the-server]__0f_4g-vazrvxv._.js} +1 -1
  62. package/.next/standalone/.next/server/chunks/_0hf5q4xzyg40j._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/_19011-u44143j._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/{_0kc-piux3hady._.js → _1y590mjzpkbos._.js} +2 -2
  68. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/src_hooks_0_em82s16zfym._.js +5 -0
  70. package/.next/standalone/.next/server/chunks/src_hooks_builtin-policies_ts_1yq5ybgx1v98e._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3h94wnn._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/src_hooks_daemon-client_ts_0il_yu1zc0291._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn_4twyh._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ts0fg5yih1c1._.js → [root-of-the-server]__00l2u6j86cb8y._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1_xbg2kx30wp-._.js → [root-of-the-server]__0naevg4bcoh54._.js} +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0vcyntk4snf0f._.js → [root-of-the-server]__1vofkb2oq7p7e._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/{_1gb0ifpkowwub._.js → _0ouu886szh6yj._.js} +10 -9
  91. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/{_1ve8y151ua7qx._.js → _1fw0_nzkae5ao._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_00jxj8foxqz5t.js → node_modules_html-to-image_es_index_1fv03uigni8s5.js} +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9ddabn3_._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +5 -0
  107. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +5 -0
  108. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4l1tzrm._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/src_hooks_daemon-client_ts_0k41_i7jg6ndv._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589gp4p60j._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me658zqmh_._.js +1 -1
  112. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  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/preview-props.json +3 -3
  116. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  117. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  118. package/.next/standalone/.next/static/chunks/{0pjxnp927y7_j.js → 0ay__-d2h1xb5.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{2znt1784j9gd9.js → 0tbm3vjm62h-2.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/1c_jf6g5wnslk.js +1 -0
  121. package/.next/standalone/.next/static/chunks/{3tn1bzk094e8e.js → 1my3-5p47t7r-.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/20py8k1gd_i1m.js +1 -0
  123. package/.next/standalone/.next/static/chunks/21hckpmsc4g70.js +6 -0
  124. package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +1 -0
  125. package/.next/standalone/.next/static/chunks/2o6d37j425uci.js +1 -0
  126. package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{2qz5piiir-qqm.js → 42xptz88mtq3x.js} +1 -1
  128. package/.next/standalone/app/actions/update-jev-config.ts +5 -3
  129. package/.next/standalone/app/settings/jev-panel.tsx +1 -1
  130. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +32 -0
  131. package/.next/standalone/fp-cloud-cli/README.md +40 -1
  132. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +91 -12
  133. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +78 -16
  134. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +84 -18
  135. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +124 -6
  136. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +89 -6
  137. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +189 -20
  138. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +8 -5
  139. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +2 -2
  140. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +14 -7
  141. package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +527 -0
  142. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +46 -0
  143. package/.next/standalone/fp-cloud-cli/tests/test_output.py +23 -0
  144. package/.next/standalone/hermes-plugin/README.md +6 -4
  145. package/.next/standalone/hermes-plugin/__init__.py +27 -0
  146. package/.next/standalone/hermes-plugin/client.py +5 -1
  147. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  148. package/.next/standalone/package.json +11 -10
  149. package/.next/standalone/sdk/python/uv.lock +31 -8
  150. package/.next/standalone/server.js +1 -1
  151. package/bin/failproofai.mjs +17 -1
  152. package/dist/cli.mjs +3507 -1250
  153. package/dist/worker.mjs +1779 -169
  154. package/hermes-plugin/README.md +6 -4
  155. package/hermes-plugin/__init__.py +27 -0
  156. package/hermes-plugin/client.py +5 -1
  157. package/hermes-plugin/plugin.yaml +1 -1
  158. package/package.json +11 -10
  159. package/src/audit/open-browser.ts +6 -7
  160. package/src/hooks/agent-roster.ts +180 -0
  161. package/src/hooks/agent-skill.ts +203 -0
  162. package/src/hooks/agent-targets.ts +60 -0
  163. package/src/hooks/cloud-connection.ts +91 -45
  164. package/src/hooks/cloud-enrollment-cli.ts +7 -4
  165. package/src/hooks/cloud-login.ts +6 -3
  166. package/src/hooks/cloud-managed-policies.ts +430 -20
  167. package/src/hooks/cloud-policy-errors.ts +481 -0
  168. package/src/hooks/codex-system-hooks.ts +10 -4
  169. package/src/hooks/config-status.ts +10 -4
  170. package/src/hooks/configure-wizard.ts +54 -26
  171. package/src/hooks/custom-hooks-loader.ts +26 -3
  172. package/src/hooks/daemon-client.ts +9 -4
  173. package/src/hooks/daemon-service.ts +2 -1
  174. package/src/hooks/delivery-health.ts +47 -2
  175. package/src/hooks/effective-reviewers.ts +36 -1
  176. package/src/hooks/fp-config.ts +19 -0
  177. package/src/hooks/fp-home.ts +6 -0
  178. package/src/hooks/handler.ts +255 -19
  179. package/src/hooks/health-probe.ts +15 -0
  180. package/src/hooks/hook-activity-store.ts +7 -1
  181. package/src/hooks/integrations.ts +36 -14
  182. package/src/hooks/jev-activity.ts +117 -1
  183. package/src/hooks/jev-cli.ts +196 -1
  184. package/src/hooks/jev-cloud-connection.ts +92 -0
  185. package/src/hooks/manager.ts +174 -34
  186. package/src/hooks/pack-failclosed.ts +2 -0
  187. package/src/hooks/pack-manifest.ts +8 -1
  188. package/src/hooks/policy-evaluator.ts +22 -1
  189. package/src/hooks/policy-registry.ts +3 -2
  190. package/src/hooks/policy-reviewability.ts +23 -5
  191. package/src/hooks/semantic/cloud-jev-health.ts +227 -0
  192. package/src/hooks/semantic/cloud-jev.ts +868 -0
  193. package/src/hooks/semantic/combine.ts +26 -1
  194. package/src/hooks/semantic/jev-client.ts +19 -2
  195. package/src/hooks/semantic/jev-config.ts +179 -30
  196. package/src/hooks/semantic/jev-review.ts +217 -10
  197. package/src/hooks/semantic/pack-policies.ts +2 -1
  198. package/src/hooks/semantic/types.ts +8 -0
  199. package/src/hooks/session-pause-cli.ts +1 -1
  200. package/src/hooks/tui.ts +13 -4
  201. package/src/hooks/uninstall-cli.ts +12 -1
  202. package/src/hooks/worker-server.ts +5 -1
  203. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0uhs2t1b._.js +0 -5
  204. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7cyqlb2._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo55j16x._.js +0 -5
  206. package/.next/standalone/.next/static/chunks/0a-ccsp3xmypz.js +0 -1
  207. package/.next/standalone/.next/static/chunks/13jv-xvabeb62.js +0 -6
  208. package/.next/standalone/.next/static/chunks/2ank2u3apy8m6.js +0 -1
  209. package/.next/standalone/.next/static/chunks/2meqb9kygrncb.js +0 -1
  210. package/.next/standalone/.next/static/chunks/2ohktp_-lkv3x.js +0 -1
  211. package/.next/standalone/.next/static/chunks/2py0eldhwz8ay.js +0 -1
  212. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
  213. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
  214. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_ssgManifest.js +0 -0
@@ -62,6 +62,7 @@ import {
62
62
  } from "./integrations";
63
63
  import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
64
64
  import { enforcingPolicyCount, installHooks, notEnforcingReason } from "./manager";
65
+ import { installAgentSkill } from "./agent-skill";
65
66
  import {
66
67
  CODEX_UNTRUSTED_NOTICE,
67
68
  codexHookTrust,
@@ -1098,6 +1099,10 @@ export async function runConfigureWizard(
1098
1099
  });
1099
1100
  };
1100
1101
 
1102
+ // Whether the saved key was refused when the CONNECT step checked it — still
1103
+ // true at the end of the run if a login there created no key.
1104
+ let savedRefused = false;
1105
+
1101
1106
  // The interactive CONNECT step. A machine with no connection, or whose saved
1102
1107
  // key is refused, starts the browser login straight away — Tab or Esc there
1103
1108
  // opens the other options. A machine that is connected with a working key gets
@@ -1108,6 +1113,7 @@ export async function runConfigureWizard(
1108
1113
  ? await transient("Checking your saved key…", () => checkCloudKey(saved.url, saved.token))
1109
1114
  : null;
1110
1115
  const savedBroken = verdict !== null && !verdict.ok && verdict.refused;
1116
+ savedRefused = savedBroken;
1111
1117
  const keptPlan: CloudPlan =
1112
1118
  verdict && verdict.ok
1113
1119
  ? { kind: "kept", how: "saved", verified: true }
@@ -1438,24 +1444,6 @@ export async function runConfigureWizard(
1438
1444
  pruneOldDaemonBinaries();
1439
1445
  }
1440
1446
 
1441
- const connectedAfterPlan = connecting || cloud.kind === "kept";
1442
- // Telemetry runs concurrently with the rest of apply (never rejects,
1443
- // 5s-bounded) and is awaited before returning. `cli` carries the selected
1444
- // agent ids, as it always has (and as hooks_installed does): a fixed list of
1445
- // product ids, never user content. The agents_* fields add counts only.
1446
- const applied = emit("configure_applied", {
1447
- target: "user",
1448
- scopes: ["user"],
1449
- cli: selected,
1450
- cli_count: selected.length,
1451
- policy_count: (readScopedHooksConfig("user", cwd).enabledPolicies ?? []).length,
1452
- connected: connecting,
1453
- mode: connectedAfterPlan ? "cloud" : "oss",
1454
- agents_selected: selected.length,
1455
- agents_detected: plan.detected.size,
1456
- agents_changed: added.length + removed.length > 0,
1457
- });
1458
-
1459
1447
  // Hooks, at user scope, for the chosen agents. Whatever policies are enabled
1460
1448
  // there are carried through untouched: `replace: true` would otherwise switch
1461
1449
  // off policies the user had turned on, and setup must never reduce
@@ -1483,6 +1471,11 @@ export async function runConfigureWizard(
1483
1471
  cwd,
1484
1472
  );
1485
1473
 
1474
+ // The failproofai skill, in every agent this machine has and any ticked
1475
+ // without being detected — traced or not, an agent asked about FailproofAI
1476
+ // should know it. A pointer to the skill online (see agent-skill.ts).
1477
+ installAgentSkill(INTEGRATION_TYPES.filter((id) => plan.detected.has(id) || selected.includes(id)));
1478
+
1486
1479
  // Agents traced again start fresh (see `requestFreshStart`). Only on a
1487
1480
  // rerun: its baseline is the selection the daemon actually filtered on. With
1488
1481
  // no saved list (upgrade, first) the daemon was collecting every agent, so an
@@ -1503,6 +1496,7 @@ export async function runConfigureWizard(
1503
1496
  let token: string | null = cloud.kind === "connect" ? cloud.token : null;
1504
1497
  let orgLabel: string | undefined = cloud.kind === "connect" ? cloud.org : undefined;
1505
1498
  let mintedName: string | undefined;
1499
+ let claimedOrg: LoginOrg | undefined;
1506
1500
  if (cloud.kind === "login" && url) {
1507
1501
  const claimed = await transient("Creating this machine's key…", () => claimLogin(url, cloud.pollSecret));
1508
1502
  void emit("configure_cloud_login", { step: "claimed", ok: claimed.kind === "ok", reason: claimed.kind });
@@ -1510,6 +1504,7 @@ export async function runConfigureWizard(
1510
1504
  token = claimed.key;
1511
1505
  orgLabel = loginOrgLabel(claimed.org);
1512
1506
  mintedName = claimed.keyName;
1507
+ claimedOrg = claimed.org;
1513
1508
  } else {
1514
1509
  hookLogWarn(`cloud login could not be completed: ${claimed.kind}`);
1515
1510
  done.push(
@@ -1536,6 +1531,8 @@ export async function runConfigureWizard(
1536
1531
  machineLabel,
1537
1532
  // Both streams unless the run said `--no-transcripts`.
1538
1533
  sessions: answers.noTranscripts !== true,
1534
+ // The org the login was approved into, for when introspect can't say.
1535
+ org: claimedOrg,
1539
1536
  }),
1540
1537
  );
1541
1538
  connected = outcome.anyConfigured;
@@ -1566,12 +1563,38 @@ export async function runConfigureWizard(
1566
1563
  updateConfig({ mode: "cloud" });
1567
1564
  }
1568
1565
 
1569
- // Logging in again on a connected machine, with no key at the end of it,
1570
- // changed nothing about the connection: the previous one is still there.
1571
- const keptOld = cloud.kind === "login" && !token && saved !== undefined;
1572
- if (keptOld) done.push(" This machine keeps its previous connection.");
1573
-
1574
- await applied;
1566
+ // Logging in again on a connected machine, with nothing connected at the end
1567
+ // of it — no key, or a key `connectToCloud` could not use, which writes
1568
+ // nothing — changed nothing about the connection: the previous one is still
1569
+ // there (never removed from this step, decision D4) — but not as if it worked
1570
+ // when its key was refused.
1571
+ const keptOld = cloud.kind === "login" && !connected && Boolean(saved);
1572
+ if (keptOld) {
1573
+ done.push(
1574
+ savedRefused
1575
+ ? " This machine keeps its previous connection, but its key no longer works. Run failproofai config again to log in."
1576
+ : " This machine keeps its previous connection.",
1577
+ );
1578
+ }
1579
+ const cloudAfter = connected || cloud.kind === "kept" || keptOld;
1580
+
1581
+ // Sent once the connection's outcome is known — a login whose claim then
1582
+ // failed must not count as connected. Never rejects, 5s-bounded. `cli`
1583
+ // carries the selected agent ids, as it always has (and as hooks_installed
1584
+ // does): a fixed list of product ids, never user content. The agents_*
1585
+ // fields add counts only.
1586
+ await emit("configure_applied", {
1587
+ target: "user",
1588
+ scopes: ["user"],
1589
+ cli: selected,
1590
+ cli_count: selected.length,
1591
+ policy_count: policies.length,
1592
+ connected,
1593
+ mode: cloudAfter ? "cloud" : "oss",
1594
+ agents_selected: selected.length,
1595
+ agents_detected: plan.detected.size,
1596
+ agents_changed: added.length + removed.length > 0,
1597
+ });
1575
1598
  // Only now — a completed apply — is the launcher considered "seen", so
1576
1599
  // first-run onboarding stops offering itself on every command.
1577
1600
  markLauncherSeen();
@@ -1588,7 +1611,7 @@ export async function runConfigureWizard(
1588
1611
  done,
1589
1612
  cwd,
1590
1613
  agents: selected.length,
1591
- connected: connected || cloud.kind === "kept" || keptOld,
1614
+ connected: cloudAfter,
1592
1615
  codexTrust,
1593
1616
  }),
1594
1617
  );
@@ -1601,7 +1624,7 @@ export async function runConfigureWizard(
1601
1624
  policies,
1602
1625
  daemonInstalled,
1603
1626
  connected,
1604
- mode: connected || cloud.kind === "kept" || keptOld ? "cloud" : "oss",
1627
+ mode: cloudAfter ? "cloud" : "oss",
1605
1628
  agents: { selected, added, removed },
1606
1629
  };
1607
1630
  }
@@ -1717,6 +1740,11 @@ export async function checkCloudKey(
1717
1740
  url: string,
1718
1741
  key: string,
1719
1742
  ): Promise<{ ok: true; org?: string; note?: string } | { ok: false; refused: boolean; reason: string }> {
1743
+ // Checked here rather than trusted: a saved address comes from a file that
1744
+ // can be edited, and an http one to another host would carry the key in
1745
+ // the clear. Not a verdict on the key, so not `refused`.
1746
+ const target = validateCloudUrl(url);
1747
+ if (!target.ok) return { ok: false, refused: false, reason: `the address ${url} can't be used: ${target.reason}` };
1720
1748
  const identity = await introspectKey(url, key);
1721
1749
  if (identity.kind === "rejected") return { ok: false, refused: true, reason: "the server did not accept it" };
1722
1750
  if (identity.kind === "unreachable") return { ok: false, refused: false, reason: identity.reason };
@@ -176,7 +176,7 @@ async function importWithDeadline(fileUrl: string): Promise<void> {
176
176
  * Does NOT clear the registry — caller is responsible for that.
177
177
  */
178
178
  export interface PolicyLoadFailure {
179
- type: "module_not_found" | "syntax_error" | "load_timeout" | "runtime_error" | "path_missing";
179
+ type: "module_not_found" | "file_not_found" | "syntax_error" | "load_timeout" | "runtime_error" | "path_missing";
180
180
  reason: string;
181
181
  }
182
182
 
@@ -250,8 +250,13 @@ async function loadSingleFile(
250
250
  return null;
251
251
  } catch (err) {
252
252
  const msg = err instanceof Error ? err.message : String(err);
253
- const errorType = /Cannot find module|MODULE_NOT_FOUND|ENOENT/i.test(msg)
253
+ // An import that names no module, apart from a file the policy itself
254
+ // reads at load (a `readFileSync` of its data): both are ENOENT underneath,
255
+ // but "module not found" sent people looking for a missing package (B13).
256
+ const errorType = /Cannot find module|MODULE_NOT_FOUND|Cannot find package/i.test(msg)
254
257
  ? "module_not_found"
258
+ : /ENOENT/i.test(msg)
259
+ ? "file_not_found"
255
260
  : /SyntaxError|Unexpected token/i.test(msg)
256
261
  ? "syntax_error"
257
262
  : /timed out loading module/i.test(msg)
@@ -334,6 +339,13 @@ export interface LoadAllResult {
334
339
  * Only present for a path more than one pack resolved to.
335
340
  */
336
341
  packAliases: Map<string, string[]>;
342
+ /**
343
+ * Import failures of FailproofAI Cloud JS policies, keyed by Cloud policy id
344
+ * (every id behind a shared artifact). Fail-open like the rest of the Cloud
345
+ * read — nothing denies over them — but reported to Cloud in `errors.json`,
346
+ * so a deployment that is not actually enforcing says so.
347
+ */
348
+ cloudFailures?: Map<string, PolicyLoadFailure>;
337
349
  }
338
350
 
339
351
  export function customPolicyId(file: string, name: string): string {
@@ -456,8 +468,12 @@ export async function loadAllCustomHooks(
456
468
  // codebase exists to remove. It is also announced, so an operator can give the
457
469
  // two policies distinguishable source instead of living with the merge.
458
470
  const cloudManagedByPath = new Map<string, CloudManagedPolicyArtifact>();
471
+ /** Every Cloud policy id behind each artifact path, for `cloudFailures`. */
472
+ const cloudIdsByPath = new Map<string, string[]>();
473
+ const cloudFailures = new Map<string, PolicyLoadFailure>();
459
474
  for (const policy of opts?.cloudManagedPolicies ?? []) {
460
475
  const key = resolve(policy.path);
476
+ cloudIdsByPath.set(key, [...(cloudIdsByPath.get(key) ?? []), policy.id]);
461
477
  const existing = cloudManagedByPath.get(key);
462
478
  if (!existing) {
463
479
  cloudManagedByPath.set(key, policy);
@@ -660,6 +676,9 @@ export async function loadAllCustomHooks(
660
676
  if (failure && pack) {
661
677
  for (const id of packIdsByPath.get(absPath) ?? [pack.id]) packFailures.set(id, failure);
662
678
  }
679
+ if (failure && cloudManaged) {
680
+ for (const id of cloudIdsByPath.get(absPath) ?? [cloudManaged.id]) cloudFailures.set(id, failure);
681
+ }
663
682
  for (const hook of getCustomHooks().slice(hooksBefore)) {
664
683
  const tagged = hook as CustomHook & {
665
684
  __policyId?: string;
@@ -680,6 +699,10 @@ export async function loadAllCustomHooks(
680
699
  }
681
700
  } else {
682
701
  hookLogWarn(`custom policy path not found: ${absPath}`);
702
+ if (cloudManaged) {
703
+ const missing = { type: "path_missing", reason: `path missing: ${absPath}` } as const;
704
+ for (const id of cloudIdsByPath.get(absPath) ?? [cloudManaged.id]) cloudFailures.set(id, missing);
705
+ }
683
706
  if (pack) {
684
707
  const missing = { type: "path_missing", reason: `path missing: ${absPath}` } as const;
685
708
  for (const id of packIdsByPath.get(absPath) ?? [pack.id]) packFailures.set(id, missing);
@@ -830,5 +853,5 @@ export async function loadAllCustomHooks(
830
853
  const winner = packByPath.get(path);
831
854
  if (winner && ids.length > 1) packAliases.set(winner.id, ids);
832
855
  }
833
- return { hooks: allHooks, conventionSources, packFailures, packAliases };
856
+ return { hooks: allHooks, conventionSources, packFailures, packAliases, cloudFailures };
834
857
  }
@@ -16,7 +16,7 @@ import { existsSync } from "node:fs";
16
16
  import { daemonSocket as daemonSocketPath } from "./fp-home";
17
17
  import { readConfig } from "./fp-config";
18
18
 
19
- const PROTOCOL_VERSION = 1;
19
+ const PROTOCOL_VERSION = 2;
20
20
 
21
21
  /**
22
22
  * Reaching the daemon and getting an answer out of it are two different
@@ -56,6 +56,8 @@ export interface DaemonHookRequest {
56
56
  * hazard.
57
57
  */
58
58
  cwd?: string;
59
+ /** Settings path of the originating agent profile, not the daemon's. */
60
+ agentSettingsPath?: string;
59
61
  }
60
62
 
61
63
  export interface DaemonHookResponse {
@@ -98,6 +100,7 @@ export interface DaemonPolicyEvaluationRequest {
98
100
  event: string;
99
101
  payload: Record<string, unknown>;
100
102
  cwd?: string;
103
+ agentSettingsPath?: string;
101
104
  }
102
105
 
103
106
  export type DaemonPolicyEvaluationAttempt =
@@ -304,6 +307,7 @@ export async function attemptDaemonHook(
304
307
  cli: req.cli,
305
308
  stdin: req.stdin,
306
309
  cwd: req.cwd,
310
+ agentSettingsPath: req.agentSettingsPath,
307
311
  },
308
312
  opts,
309
313
  );
@@ -338,9 +342,9 @@ function isStringArray(value: unknown): value is string[] {
338
342
 
339
343
  /**
340
344
  * Attempts the structured request used by native in-process integrations.
341
- * A matching protocol version is not sufficient: pre-native v1 daemons speak
342
- * `hook` but cannot deserialize `policyEvaluation`, so only a complete,
343
- * well-shaped `policyResult` proves this capability exists.
345
+ * A matching protocol version is not sufficient: a broken or partial daemon
346
+ * may speak `hook` but not `policyEvaluation`, so only a complete, well-shaped
347
+ * `policyResult` proves this capability exists.
344
348
  */
345
349
  export async function attemptDaemonPolicyEvaluation(
346
350
  req: DaemonPolicyEvaluationRequest,
@@ -354,6 +358,7 @@ export async function attemptDaemonPolicyEvaluation(
354
358
  event: req.event,
355
359
  payload: req.payload,
356
360
  cwd: req.cwd,
361
+ agentSettingsPath: req.agentSettingsPath,
357
362
  },
358
363
  opts,
359
364
  );
@@ -7,6 +7,7 @@
7
7
  * `configure-wizard.ts` calls the functions here directly, the same
8
8
  * relationship it already has with `manager.ts`'s `installHooks()`.
9
9
  */
10
+ import { HEALTH_PROBE_SOURCE } from "./health-probe";
10
11
  import {
11
12
  existsSync,
12
13
  mkdirSync,
@@ -1030,7 +1031,7 @@ export async function probeDaemon(): Promise<DaemonProbe> {
1030
1031
  cli: "claude",
1031
1032
  stdin: JSON.stringify({
1032
1033
  hook_event_name: "SessionStart",
1033
- source: "failproofai-health-probe",
1034
+ source: HEALTH_PROBE_SOURCE,
1034
1035
  }),
1035
1036
  },
1036
1037
  { responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS },
@@ -23,11 +23,20 @@
23
23
  * `is_auto_retryable()` excludes it from the retry pass — that is correct, and
24
24
  * re-litigating it here would burn the budget of batches that could succeed.
25
25
  * The gap was never the detection. It was that the detection was mute.
26
+ *
27
+ * A credential refusal is only evidence about the CURRENT key while nothing
28
+ * has been accepted since. A key regenerated on the dashboard leaves the
29
+ * running collector on the old secret until the daemon's next config tick, so
30
+ * a batch or two is refused while the new key already works. Those parked
31
+ * before the collector's last accepted delivery (`delivery.last_ok_ts`) are
32
+ * counted as `resending`, not refused: the daemon re-sends them with the key
33
+ * that works (`Delivery::redrive_refused`) and drops one only if that key
34
+ * refuses it too.
26
35
  */
27
- import { existsSync, readdirSync, statSync } from "node:fs";
36
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
28
37
  import { join } from "node:path";
29
38
 
30
- import { failedDir } from "./fp-home";
39
+ import { collectorHealthFile, failedDir } from "./fp-home";
31
40
  import { readCredentials } from "./fp-config";
32
41
 
33
42
  /**
@@ -106,6 +115,13 @@ export interface DeliveryHealth {
106
115
  credentialRejected: number;
107
116
  /** Count per client status, e.g. `{ 401: 26 }`. */
108
117
  byStatus: Record<number, number>;
118
+ /**
119
+ * Credential refusals parked BEFORE the last delivery the server accepted.
120
+ * The key in use now works, so these are not counted as refused: the daemon
121
+ * re-sends them with that key (`Delivery::redrive_refused`, fpai-collect)
122
+ * and drops one only if the current key refuses it too.
123
+ */
124
+ resending: number;
109
125
  /** Age of the oldest parked batch, in ms. Undefined when none are parked. */
110
126
  oldestAgeMs?: number;
111
127
  }
@@ -115,8 +131,23 @@ const EMPTY: DeliveryHealth = {
115
131
  rejected: 0,
116
132
  credentialRejected: 0,
117
133
  byStatus: {},
134
+ resending: 0,
118
135
  };
119
136
 
137
+ /**
138
+ * `delivery.last_ok_ts` from the collector's health file, in ms, or undefined.
139
+ * The last upload the server ACCEPTED (`UploadMetrics::last_ok_ts`).
140
+ */
141
+ export function readLastDeliveredAt(home?: string): number | undefined {
142
+ try {
143
+ const raw = JSON.parse(readFileSync(collectorHealthFile(home), "utf8")) as { delivery?: { last_ok_ts?: unknown } };
144
+ const ts = raw?.delivery?.last_ok_ts;
145
+ return typeof ts === "number" && Number.isFinite(ts) && ts > 0 ? ts * 1000 : undefined;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }
150
+
120
151
  /**
121
152
  * Summarise `failed/` without touching it.
122
153
  *
@@ -127,6 +158,7 @@ export function deliveryHealth(
127
158
  home?: string,
128
159
  now: number = Date.now(),
129
160
  connectedAt: number | undefined = home ? undefined : readCredentials().connectedAt,
161
+ lastDeliveredAt: number | undefined = home ? undefined : readLastDeliveredAt(),
130
162
  ): DeliveryHealth {
131
163
  const dir = failedDir(home);
132
164
  if (!existsSync(dir)) return EMPTY;
@@ -144,6 +176,7 @@ export function deliveryHealth(
144
176
  let total = 0;
145
177
  let rejected = 0;
146
178
  let credentialRejected = 0;
179
+ let resending = 0;
147
180
  let oldestMtime: number | undefined;
148
181
 
149
182
  for (const name of entries) {
@@ -162,6 +195,17 @@ export function deliveryHealth(
162
195
  CREDENTIAL_STATUSES.has(parsed.clientStatus) &&
163
196
  mtime < connectedAt
164
197
  ) continue;
198
+ // Refused with a key that has worked since: the latest delivery attempt
199
+ // was accepted, so the CURRENT key is not the one being refused.
200
+ if (
201
+ lastDeliveredAt !== undefined &&
202
+ parsed.clientStatus !== undefined &&
203
+ CREDENTIAL_STATUSES.has(parsed.clientStatus) &&
204
+ mtime <= lastDeliveredAt
205
+ ) {
206
+ resending += 1;
207
+ continue;
208
+ }
165
209
  total += 1;
166
210
  if (parsed.clientStatus !== undefined) {
167
211
  rejected += 1;
@@ -177,6 +221,7 @@ export function deliveryHealth(
177
221
  rejected,
178
222
  credentialRejected,
179
223
  byStatus,
224
+ resending,
180
225
  ...(oldestMtime !== undefined ? { oldestAgeMs: Math.max(0, now - oldestMtime) } : {}),
181
226
  };
182
227
  }
@@ -12,6 +12,15 @@
12
12
  * never installed its checks: `jevChecksInstalled` below is the hook path's
13
13
  * gate for starting a review at all.
14
14
  *
15
+ * ## …and a FailproofAI Cloud `both` policy's own Cloud checks
16
+ *
17
+ * A `both` policy's JS half names its own Jev checks, which run on FailproofAI
18
+ * Cloud and never reach this machine (CONTRACT C10). While Cloud's Jev mode
19
+ * asks (`observe`/`enforce`), those names are reviewers too — but only as
20
+ * `cloud:<policyId>/<name>` (`cloudReviewerName`), which the Cloud review files
21
+ * that policy's own Cloud outcomes under. An installed pack's check of the same
22
+ * name is a different reviewer and can never clear it.
23
+ *
15
24
  * The names come from the MANIFEST, which is already where `authority` and
16
25
  * `reviewedBy` themselves are read from, so this adds no new trust and imports
17
26
  * no semantic module.
@@ -36,11 +45,16 @@
36
45
  */
37
46
  import { readInstalledPacks, type ResolvedPack } from "./pack-manifest";
38
47
  import { NO_REVIEWERS, SEMANTIC_REVIEWER_NAMES } from "./policy-authority";
48
+ import { cloudReviewerNames, readCloudAuthorityInputs, readCloudJevMode } from "./cloud-managed-policies";
49
+ import type { AgentIdentity } from "./agent-targets";
39
50
 
40
51
  let cached: ReadonlySet<string> | null = null;
52
+ /** Whether the installed packs alone (not Cloud) declared any check, for the cached answer. */
53
+ let cachedPackChecks = false;
41
54
  let cachedContested: ReadonlyMap<string, string[]> = new Map();
42
55
  /** The agent the current registration pass is for; see {@link forgetEffectiveReviewerNames}. */
43
56
  let reviewerCli: string | undefined;
57
+ let reviewerAgent: AgentIdentity | null = null;
44
58
 
45
59
  /**
46
60
  * The packs whose Jev checks take part for `cli`, filtered the way the regex
@@ -173,13 +187,23 @@ export function effectiveReviewerNames(): ReadonlySet<string> {
173
187
  if (cached) return cached;
174
188
  let names: ReadonlySet<string> = NO_REVIEWERS;
175
189
  cachedContested = new Map();
190
+ cachedPackChecks = false;
176
191
  try {
177
192
  const packs = jevPacks(readInstalledPacks().packs, reviewerCli);
178
193
  names = reviewerNamesFor(packs);
194
+ cachedPackChecks = names.size > 0;
179
195
  cachedContested = contestedSemanticNames(packs);
180
196
  } catch {
181
197
  // See above: an unreadable manifest declares no checks.
182
198
  }
199
+ try {
200
+ // Both reads answer "nothing" for a file they cannot use; guarded anyway,
201
+ // because a throw here would cost the registration pass — every policy.
202
+ const cloud = cloudReviewerNames(readCloudAuthorityInputs(reviewerAgent), readCloudJevMode());
203
+ if (cloud.length > 0) names = new Set([...names, ...cloud]);
204
+ } catch {
205
+ // No Cloud reviewers: every `both` policy stays hard.
206
+ }
183
207
  cached = names;
184
208
  return names;
185
209
  }
@@ -204,6 +228,16 @@ export function jevChecksInstalled(): boolean {
204
228
  return effectiveReviewerNames().size > 0;
205
229
  }
206
230
 
231
+ /**
232
+ * Whether an installed pack (not FailproofAI Cloud) gives Jev a check for this
233
+ * agent: the part of {@link jevChecksInstalled} a Cloud review needs to know
234
+ * when deciding whether a call has anything of the machine's own to ask.
235
+ */
236
+ export function packJevChecksInstalled(): boolean {
237
+ effectiveReviewerNames();
238
+ return cachedPackChecks;
239
+ }
240
+
207
241
  /**
208
242
  * The same question for a CLI command, read fresh rather than from the
209
243
  * registration cache (which only the hook path fills): any agent, packs as
@@ -244,7 +278,8 @@ export function reviewerNamesFor(
244
278
  * runs before it registers anything — so a pack installed under a long-lived
245
279
  * warm worker is picked up on the next event rather than at the next restart.
246
280
  */
247
- export function forgetEffectiveReviewerNames(cli?: string): void {
281
+ export function forgetEffectiveReviewerNames(cli?: string, agent: AgentIdentity | null = null): void {
248
282
  cached = null;
249
283
  reviewerCli = cli;
284
+ reviewerAgent = agent;
250
285
  }
@@ -940,6 +940,25 @@ export function readCredentials(): FpCredentials {
940
940
  }
941
941
  }
942
942
 
943
+ /**
944
+ * Whether FailproofAI Cloud sets this machine's Jev mode: it is connected for
945
+ * policies (a machine id in `credentials.json`). Cloud then runs Jev in enforce
946
+ * while a Jev policy is deployed here and switches it off when none is; local
947
+ * mode settings do not apply. Never throws.
948
+ */
949
+ export function cloudManagesJevMode(): boolean {
950
+ try {
951
+ const cloud = readCredentials().cloud;
952
+ return Boolean(cloud?.url && cloud.machineId);
953
+ } catch {
954
+ return false;
955
+ }
956
+ }
957
+
958
+ /** The one line a local mode switch answers with on such a machine. */
959
+ export const CLOUD_MANAGED_JEV_MODE =
960
+ "Jev mode is managed by FailproofAI Cloud on this machine: enforce while a Jev policy is deployed here, off when none is.";
961
+
943
962
  /**
944
963
  * Project a parsed credentials object onto {@link FpCredentials}.
945
964
  *
@@ -165,6 +165,9 @@ export const credentialsFile = (home?: string) => atHome(home, "credentials.json
165
165
  */
166
166
  export const jevConfigFile = (home?: string) => atHome(home, "jev.json");
167
167
 
168
+ /** Owner-only inventory maintained by failproofaid. Paths stay on this machine. */
169
+ export const agentRosterFile = (home?: string) => atHome(home, "agents", "roster.json");
170
+
168
171
  // ── Daemon binaries ──────────────────────────────────────────────────────────
169
172
 
170
173
  export const binDir = (home?: string) => atHome(home, "bin");
@@ -656,6 +659,9 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da
656
659
  // history the user was already told about. Kept OUT of `auditSessionFile`
657
660
  // precisely so both survive a sign-out.
658
661
  { path: auditMachineFile, class: "identity" },
662
+ // Profile IDs are stable across restarts and renames. A reset that deleted
663
+ // this file would make Cloud's exact-profile assignments match nobody.
664
+ { path: agentRosterFile, class: "identity" },
659
665
 
660
666
  // ── May be dropped: rebuilt on demand ──
661
667
  // NOTE: `auditDir` itself is deliberately absent. Layout 4 made it MIXED — it