failproofai 0.0.14-beta.1 → 0.0.14-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 (164) 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 +7 -7
  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 +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  35. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/app/index.html +1 -1
  38. package/.next/standalone/.next/server/app/index.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  40. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  41. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  42. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  43. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  44. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  48. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0--lkk6._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-219ec._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__04_h-a5._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08w4wmd._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g6bbuw._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q0qzx1._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q2nbsl._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__16le-kd._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ffkmds._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1legmza._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mrihkj._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qb590j._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1shcqgr._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1xuedea._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  83. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__015_i4t._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__030k0c6._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03lv-pe._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__05r_17v._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0wp9w6i._.js → [root-of-the-server]__09v9cwb._.js} +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e2l3c1._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hci7t3._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hdsupo._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p84xee._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qeuy9c._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s89hoe._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0brjczq._.js → [root-of-the-server]__0twi4fj._.js} +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ynf7tx._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17ai7sy._.js +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1nq7ivq._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tkjqka._.js +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/_05whahf._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/_1kje4fm._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  104. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/lib_0xkhw_v._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_12rjfea.js → node_modules_html-to-image_es_index_06xsmte.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  110. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  111. package/.next/standalone/.next/server/pages/404.html +1 -1
  112. package/.next/standalone/.next/server/pages/500.html +1 -1
  113. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  114. package/.next/standalone/.next/server/server-reference-manifest.json +10 -10
  115. package/.next/standalone/.next/static/chunks/{0o9wpnze1ey8y.js → 0h-6ymo9v86b0.js} +2 -2
  116. package/.next/standalone/.next/static/chunks/0pndjhcxfu-0t.js +1 -0
  117. package/.next/standalone/.next/static/chunks/1-33f82_l32or.js +1 -0
  118. package/.next/standalone/.next/static/chunks/{2vav_x0_336ds.js → 1bg_g3g359c_d.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{1359o5785rmve.js → 1gg-8e9q_m__9.js} +2 -2
  120. package/.next/standalone/.next/static/chunks/{0emg30_6svycu.js → 29zkom4s9de2d.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{0d5lypu1zs21r.js → 2llkdafnaejw_.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{20tuctw38jbgw.js → 388hgfqye67xa.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/396764jgd7-9j.js +1 -0
  124. package/.next/standalone/.next/static/chunks/3w7g0n0iwwnbs.js +1 -0
  125. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +11 -2
  126. package/.next/standalone/app/components/copy-button.tsx +22 -6
  127. package/.next/standalone/{ci/cli-integration → integration-suite}/README.md +25 -7
  128. package/.next/standalone/integration-suite/ci-entrypoint.sh +164 -0
  129. package/.next/standalone/{ci/cli-integration → integration-suite}/run.sh +4 -4
  130. package/.next/standalone/lib/format-duration.ts +23 -9
  131. package/.next/standalone/package.json +1 -1
  132. package/.next/standalone/server.js +1 -1
  133. package/dist/cli.mjs +342 -106
  134. package/lib/format-duration.ts +23 -9
  135. package/package.json +1 -1
  136. package/scripts/translate-docs/cli.ts +6 -0
  137. package/scripts/translate-docs/mdx-translator.ts +30 -19
  138. package/scripts/translate-docs/readme-translator.ts +31 -21
  139. package/scripts/translate-docs/translator.ts +142 -7
  140. package/scripts/translate-docs/types.ts +6 -0
  141. package/scripts/translate-docs/validate-translation.ts +132 -0
  142. package/scripts/validate-mdx.ts +80 -11
  143. package/src/audit/cli.ts +12 -6
  144. package/src/auth/cli.ts +14 -5
  145. package/src/hooks/builtin-policies.ts +168 -50
  146. package/src/hooks/configure-wizard.ts +238 -22
  147. package/src/hooks/custom-hooks-loader.ts +70 -4
  148. package/src/hooks/handler.ts +4 -1
  149. package/src/hooks/policy-types.ts +8 -0
  150. package/src/hooks/tui.ts +95 -28
  151. package/.next/standalone/.next/static/chunks/2ldhnaweu9ew3.js +0 -1
  152. package/.next/standalone/.next/static/chunks/3cg7x0joi2b98.js +0 -1
  153. package/.next/standalone/.next/static/chunks/3k87p-tijud-4.js +0 -1
  154. package/.next/standalone/.next/static/chunks/3kmaqcp7rldad.js +0 -1
  155. /package/.next/standalone/.next/static/{LkN-JjmhKGmL-WklH7WVi → YKxI22hC-F_nHp7MEKfb1}/_buildManifest.js +0 -0
  156. /package/.next/standalone/.next/static/{LkN-JjmhKGmL-WklH7WVi → YKxI22hC-F_nHp7MEKfb1}/_clientMiddlewareManifest.js +0 -0
  157. /package/.next/standalone/.next/static/{LkN-JjmhKGmL-WklH7WVi → YKxI22hC-F_nHp7MEKfb1}/_ssgManifest.js +0 -0
  158. /package/.next/standalone/{ci/cli-integration → integration-suite}/Dockerfile +0 -0
  159. /package/.next/standalone/{ci/cli-integration → integration-suite}/canary-policies.mjs +0 -0
  160. /package/.next/standalone/{ci/cli-integration → integration-suite}/capture-tokens.sh +0 -0
  161. /package/.next/standalone/{ci/cli-integration → integration-suite}/inject-tokens.sh +0 -0
  162. /package/.next/standalone/{ci/cli-integration → integration-suite}/install-clis.sh +0 -0
  163. /package/.next/standalone/{ci/cli-integration → integration-suite}/probe-cli.sh +0 -0
  164. /package/.next/standalone/{ci/cli-integration → integration-suite}/report.js +0 -0
@@ -12,19 +12,29 @@
12
12
  * whatever is already enabled, so unticking removes). Reuses the tested
13
13
  * install/uninstall manager and the existing searchable policy picker.
14
14
  */
15
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { resolve, sep } from "node:path";
18
18
 
19
- import { selectOne, multiSelect, intro, outro, summarize, type TTYIn, type TTYOut } from "./tui";
19
+ import {
20
+ selectOne,
21
+ multiSelect,
22
+ intro,
23
+ outro,
24
+ summarize,
25
+ type MultiChoice,
26
+ type TTYIn,
27
+ type TTYOut,
28
+ } from "./tui";
20
29
  import {
21
30
  detectInstalledClis,
22
31
  getIntegration,
23
32
  } from "./integrations";
24
33
  import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
25
34
  import { installHooks } from "./manager";
26
- import { getConfigPathForScope } from "./hooks-config";
35
+ import { getConfigPathForScope, readHooksConfig } from "./hooks-config";
27
36
  import { POLICY_PRESETS, resolvePreset, resolveEverything } from "./policy-presets";
37
+ import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
28
38
  import { trackHookEvent } from "./hook-telemetry";
29
39
  import { getInstanceId } from "../../lib/telemetry-id";
30
40
 
@@ -75,7 +85,13 @@ export function buildScopeChoices(cwd: string) {
75
85
  ];
76
86
  }
77
87
 
78
- export function buildAgentChoices(scope: HookScope, cwd: string) {
88
+ /** The CLIs that can actually be configured at `scope`. Hermes and OpenClaw
89
+ * are gateways with no project-level config, so they are user-scope only. */
90
+ export function clisSupportingScope(scope: HookScope): IntegrationType[] {
91
+ return INTEGRATION_TYPES.filter((id) => getIntegration(id).scopes.includes(scope));
92
+ }
93
+
94
+ export function buildAgentChoices(scope: HookScope, cwd: string): MultiChoice<IntegrationType>[] {
79
95
  const detected = new Set(detectInstalledClis());
80
96
  // Detected first, then the rest as "install ahead of time".
81
97
  const ordered = [
@@ -85,6 +101,25 @@ export function buildAgentChoices(scope: HookScope, cwd: string) {
85
101
  return ordered.map((id) => {
86
102
  const integration = getIntegration(id);
87
103
  const isDetected = detected.has(id);
104
+
105
+ // Not every CLI can be configured at every scope — Hermes and OpenClaw
106
+ // have no project config at all. Offering them anyway meant picking "Just
107
+ // this project" and applying died with `Scope "project" is not supported
108
+ // by Hermes`, after the user had answered every question. Show them as
109
+ // locked and unchecked with the reason, so the constraint is visible
110
+ // instead of being discovered as a crash.
111
+ const supported = integration.scopes.includes(scope);
112
+ if (!supported) {
113
+ return {
114
+ label: integration.displayName,
115
+ value: id,
116
+ checked: false,
117
+ locked: true,
118
+ section: "Global only · not configurable per-project",
119
+ hint: `supports ${integration.scopes.join(", ")} scope — rerun with "Everywhere I code"`,
120
+ };
121
+ }
122
+
88
123
  let installedHere = false;
89
124
  try {
90
125
  installedHere = integration.hooksInstalledInSettings(scope, cwd);
@@ -103,18 +138,62 @@ export function buildAgentChoices(scope: HookScope, cwd: string) {
103
138
 
104
139
  const EVERYTHING = "__everything__";
105
140
  const ALL_CLIS = "__all_clis__";
141
+ /** Sentinel for the locked "Custom" row — informational, never resolves to
142
+ * builtin policy names (custom policies load by convention, not by config). */
143
+ const CUSTOM = "__custom__";
106
144
 
107
145
  /** The themed preset bundles for the wizard's multi-select, plus an "Everything"
108
146
  * option that enables the full builtin policy set. */
109
- export function buildPresetChoices() {
110
- const choices: Array<{ label: string; value: string; hint: string }> = POLICY_PRESETS.map(
111
- (p) => ({ label: p.label, value: p.id, hint: p.description }),
112
- );
147
+ export function buildPresetChoices(cwd: string = process.cwd(), enabled = true) {
148
+ const choices: MultiChoice<string>[] = POLICY_PRESETS.map((p) => ({
149
+ label: p.label,
150
+ value: p.id,
151
+ hint: p.description,
152
+ }));
113
153
  choices.push({
114
154
  label: "Everything",
115
155
  value: EVERYTHING,
116
156
  hint: `all ${resolveEverything().length} policies`,
117
157
  });
158
+
159
+ // The Custom row is ALWAYS present, because it is the only place the feature
160
+ // is discoverable: a user who has never written a policy cannot learn the
161
+ // capability exists, and one who wrote a badly-named file cannot learn why
162
+ // nothing happened.
163
+ //
164
+ // When there are loadable files it is a REAL checkbox — unticking writes
165
+ // `customPoliciesEnabled: false`, which switches convention discovery off
166
+ // without renaming or deleting anything. With nothing to toggle (no files,
167
+ // or only skipped ones) it falls back to a locked status row.
168
+ const custom = describeCustomPolicies(cwd);
169
+ const skipped = custom.warnings.length;
170
+ const plural = (n: number) => `${n} file${n === 1 ? "" : "s"}`;
171
+ const skippedNote = skipped > 0 ? ` · ${skipped} skipped, see next screen` : "";
172
+
173
+ if (custom.fileCount > 0) {
174
+ choices.push({
175
+ label: "Custom",
176
+ value: CUSTOM,
177
+ checked: enabled,
178
+ // Deliberately NOT summaryExclude'd: this one is a real choice, and the
179
+ // step summary is the only place the user sees what they picked. Hiding
180
+ // it meant unticking Custom and every bundle showed "none", giving no
181
+ // way to tell the toggle had registered.
182
+ hint: `${plural(custom.fileCount)} in ${custom.scopes.join(" + ")}${skippedNote}`,
183
+ });
184
+ } else {
185
+ choices.push({
186
+ label: "Custom",
187
+ value: CUSTOM,
188
+ locked: true,
189
+ checked: false,
190
+ summaryExclude: true,
191
+ hint:
192
+ skipped > 0
193
+ ? `${plural(skipped)} found but NOT loaded — see next screen`
194
+ : "none yet · drop *-policies.mjs in .failproofai/policies/",
195
+ });
196
+ }
118
197
  return choices;
119
198
  }
120
199
 
@@ -124,14 +203,100 @@ export function buildPresetChoices() {
124
203
  * enables the full policy set and wins over any presets.
125
204
  */
126
205
  export function resolvePresetSelection(values: string[]): string[] {
127
- if (values.includes(EVERYTHING)) return resolveEverything();
128
- return [...new Set(values.flatMap((id) => resolvePreset(id)))];
206
+ // The Custom row is informational — custom policies are discovered from disk
207
+ // by the loader, never named in the enabled-policies config — so it must not
208
+ // reach resolvePreset(), which only knows builtin bundle ids.
209
+ const selected = values.filter((v) => v !== CUSTOM);
210
+ if (selected.includes(EVERYTHING)) return resolveEverything();
211
+ return [...new Set(selected.flatMap((id) => resolvePreset(id)))];
212
+ }
213
+
214
+ const DIM_NOTE = "(auto-loaded)";
215
+
216
+ /**
217
+ * Persist the Custom checkbox into the scope's config, after installHooks has
218
+ * written it (installHooks copies the previous config forward, so writing
219
+ * first would be overwritten).
220
+ *
221
+ * Writes the key only to turn discovery OFF, and removes it when turning back
222
+ * on, so the common case leaves no `customPoliciesEnabled: true` noise in the
223
+ * file and "absent means enabled" stays the single default. `undefined` means
224
+ * there was nothing to toggle — leave whatever is there alone.
225
+ */
226
+ export function setCustomPoliciesEnabled(
227
+ scope: HookScope,
228
+ cwd: string,
229
+ enabled: boolean | undefined,
230
+ ): void {
231
+ if (enabled === undefined) return;
232
+ const path = getConfigPathForScope(scope, cwd);
233
+ let config: Record<string, unknown> = {};
234
+ try {
235
+ if (existsSync(path)) config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
236
+ } catch {
237
+ return; // a malformed config is the install path's problem, not ours
238
+ }
239
+ if (enabled) delete config.customPoliciesEnabled;
240
+ else config.customPoliciesEnabled = false;
241
+ try {
242
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
243
+ } catch {
244
+ /* best-effort: never fail a completed setup over this flag */
245
+ }
129
246
  }
130
247
 
131
- export function reviewLines(
132
- state: { scope: HookScope; clis: IntegrationType[]; policies: string[]; cwd: string },
133
- ): string[] {
134
- const { scope, clis, policies, cwd } = state;
248
+ /**
249
+ * Summarise the custom policy files on disk, for the review screen.
250
+ *
251
+ * Only lists files it deliberately does NOT load them. Loading executes
252
+ * user code, which is fine on the hook path but wrong in an interactive
253
+ * wizard the user hasn't confirmed yet.
254
+ *
255
+ * `warnings` covers the silent-skip trap: a file in the right directory whose
256
+ * name doesn't end in `policies.{js,mjs,ts}` is ignored entirely, with nothing
257
+ * on screen to say so. Surfacing it here is the difference between "my rule
258
+ * isn't working and I don't know why" and a one-line rename.
259
+ */
260
+ export function describeCustomPolicies(cwd: string): {
261
+ active: string[];
262
+ warnings: string[];
263
+ fileCount: number;
264
+ scopes: string[];
265
+ } {
266
+ const active: string[] = [];
267
+ const warnings: string[] = [];
268
+ const scopes: string[] = [];
269
+ let fileCount = 0;
270
+ const dirs: Array<{ dir: string; label: string }> = [
271
+ { dir: resolve(cwd, ".failproofai", "policies"), label: "project" },
272
+ { dir: resolve(homedir(), ".failproofai", "policies"), label: "global" },
273
+ ];
274
+ for (const { dir, label } of dirs) {
275
+ const found = discoverPolicyFiles(dir);
276
+ if (found.length > 0) {
277
+ active.push(`${found.length} file${found.length === 1 ? "" : "s"} (${label})`);
278
+ scopes.push(label);
279
+ fileCount += found.length;
280
+ }
281
+ for (const name of findSkippedPolicyFiles(dir)) {
282
+ warnings.push(
283
+ `! ${homeify(resolve(dir, name))} is NOT loaded — rename to ` +
284
+ `${name.replace(/\.(js|mjs|ts)$/, "-policies.$1")}`,
285
+ );
286
+ }
287
+ }
288
+ return { active, warnings, fileCount, scopes };
289
+ }
290
+
291
+ export function reviewLines(state: {
292
+ scope: HookScope;
293
+ clis: IntegrationType[];
294
+ policies: string[];
295
+ cwd: string;
296
+ /** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
297
+ customEnabled?: boolean;
298
+ }): string[] {
299
+ const { scope, clis, policies, cwd, customEnabled } = state;
135
300
  const where =
136
301
  scope === "project" ? `This project (${homeify(cwd)})` : "Everywhere (global)";
137
302
  const lines: string[] = [];
@@ -139,6 +304,20 @@ export function reviewLines(
139
304
  lines.push(` Where : ${where}`);
140
305
  lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
141
306
  lines.push(` Policies : ${policies.length} enabled`);
307
+
308
+ // Reflect the Custom decision, not just what is on disk. Reporting
309
+ // "1 file (project) (auto-loaded)" after the user had just unticked the row
310
+ // stated the opposite of what was about to happen.
311
+ const custom = describeCustomPolicies(cwd);
312
+ if (custom.active.length > 0) {
313
+ lines.push(
314
+ ` Custom : ${custom.active.join(" · ")} ${
315
+ customEnabled === false ? "— DISABLED, will not load" : DIM_NOTE
316
+ }`,
317
+ );
318
+ }
319
+ for (const warning of custom.warnings) lines.push(` ${warning}`);
320
+
142
321
  lines.push("");
143
322
  lines.push(" This will update:");
144
323
  for (const cli of clis) {
@@ -279,7 +458,12 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
279
458
  {
280
459
  label: "Everything available",
281
460
  value: ALL_CLIS,
282
- hint: `protect all ${INTEGRATION_TYPES.length} supported CLIs`,
461
+ // Counts only what this scope can actually take — expanding to all 12
462
+ // under project scope is what crashed the apply on Hermes.
463
+ hint: `protect all ${clisSupportingScope(scope).length} CLIs configurable here`,
464
+ // A selector, not an assistant. Counting it gave "13 assistants" for
465
+ // the 12 supported CLIs, and listed "Everything available" among them.
466
+ summaryExclude: true,
283
467
  },
284
468
  ...buildAgentChoices(scope, cwd),
285
469
  ],
@@ -290,15 +474,28 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
290
474
  stdout,
291
475
  });
292
476
  if (clisSel === null) return cancel();
293
- const clis: IntegrationType[] = clisSel.includes(ALL_CLIS)
294
- ? [...INTEGRATION_TYPES]
295
- : (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[]);
477
+ // Filter to what this scope supports in BOTH branches: "Everything
478
+ // available" must not expand to CLIs that cannot take this scope, and a
479
+ // locked row can't be ticked but belt-and-braces keeps the invariant local
480
+ // to the one place `clis` is built.
481
+ const supported = new Set(clisSupportingScope(scope));
482
+ const clis: IntegrationType[] = (
483
+ clisSel.includes(ALL_CLIS)
484
+ ? [...INTEGRATION_TYPES]
485
+ : (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[])
486
+ ).filter((id) => supported.has(id));
296
487
 
297
488
  // 3 — Which policies? Multi-select of themed presets — additive, so the
298
489
  // enabled set is the union of every ticked bundle.
490
+ // Seed the Custom checkbox from whatever the config already says, so the
491
+ // wizard shows the current state rather than resetting it every run.
492
+ const customEnabledBefore = readHooksConfig().customPoliciesEnabled !== false;
493
+ const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
494
+ const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
495
+
299
496
  const presets = await multiSelect<string>({
300
497
  message: "What should we guard against?",
301
- choices: buildPresetChoices(),
498
+ choices: presetChoices,
302
499
  minSelected: 1,
303
500
  summaryNoun: "bundles",
304
501
  hint: "space toggles · combine presets · ↵ confirm",
@@ -307,11 +504,14 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
307
504
  });
308
505
  if (presets === null) return cancel();
309
506
  const policies = resolvePresetSelection(presets);
507
+ // Only meaningful when there are files to switch off; with none, the row is
508
+ // locked-unchecked and must not write a disabling flag.
509
+ const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
310
510
 
311
511
  // 4 — Review & apply
312
512
  const decision = await selectOne<"apply" | "cancel">({
313
513
  message: "Ready to apply?",
314
- body: reviewLines({ scope, clis, policies, cwd }),
514
+ body: reviewLines({ scope, clis, policies, cwd, customEnabled }),
315
515
  choices: [
316
516
  { label: "Yes, apply now", value: "apply", hint: "write the config" },
317
517
  { label: "Cancel", value: "cancel", hint: "quit, no changes" },
@@ -345,12 +545,28 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
345
545
  clis,
346
546
  { replace: true, quiet: true },
347
547
  );
548
+ setCustomPoliciesEnabled(scope, cwd, customEnabled);
348
549
  await applied;
349
550
  // Only now — a completed apply — is the launcher considered "seen", so the
350
551
  // first-run bare invocation stops redirecting here and opens the dashboard.
351
552
  markLauncherSeen();
352
553
 
353
- const agentNames = clis.map((c) => getIntegration(c).displayName).join(", ");
354
- outro(`Setup complete ${policies.length} policies guarding ${agentNames}.`, { ok: true }, stdout);
554
+ // Keep this inside a standard 80-column terminal. `writeLines` truncates with
555
+ // a hard cut and no ellipsis, so an over-long line doesn't just lose its tail
556
+ // — it reads as broken output. Naming all ten CLIs took it to 182 characters;
557
+ // the count alone carries the same information, and the user picked them two
558
+ // screens ago.
559
+ const customNote =
560
+ customEnabled === true
561
+ ? " + your custom policies"
562
+ : customEnabled === false
563
+ ? " · custom policies DISABLED"
564
+ : "";
565
+ const assistants = `${clis.length} assistant${clis.length === 1 ? "" : "s"}`;
566
+ outro(
567
+ `Setup complete — ${policies.length} policies${customNote} · ${assistants}`,
568
+ { ok: true },
569
+ stdout,
570
+ );
355
571
  return { applied: true, scope, clis, policies };
356
572
  }
@@ -27,6 +27,9 @@ const LOADING_KEY = "__FAILPROOFAI_LOADING_HOOKS__";
27
27
  /** Regex matching convention policy filenames: *policies.{js,mjs,ts} */
28
28
  const CONVENTION_FILE_RE = /policies\.(js|mjs|ts)$/;
29
29
 
30
+ /** Script extensions we could load, used to spot near-miss filenames. */
31
+ const LOADABLE_EXT_RE = /\.(js|mjs|ts)$/;
32
+
30
33
  /**
31
34
  * Scan a directory for convention policy files (*policies.{js,mjs,ts}).
32
35
  * Returns sorted absolute paths. Returns [] if the directory doesn't exist.
@@ -44,6 +47,36 @@ export function discoverPolicyFiles(dir: string): string[] {
44
47
  }
45
48
  }
46
49
 
50
+ /**
51
+ * Script files sitting in a policies directory that the convention will NOT
52
+ * load, because the name doesn't end in `policies.{js,mjs,ts}`.
53
+ *
54
+ * The failure this exists for is silent and total: a file in exactly the right
55
+ * directory, exporting exactly the right thing, named `block-foo.mjs` instead
56
+ * of `block-foo-policies.mjs`, is skipped with no warning — it looks installed
57
+ * and enforces nothing. This repo shipped `block-version-bumps.mjs` that way,
58
+ * so the guard written after a bad version bump had never once run. Callers
59
+ * surface these as a warning rather than loading them, since a file that opts
60
+ * out of the naming convention may well not be a policy file at all.
61
+ */
62
+ export function findSkippedPolicyFiles(dir: string): string[] {
63
+ if (!existsSync(dir)) return [];
64
+ try {
65
+ return readdirSync(dir, { withFileTypes: true })
66
+ .filter(
67
+ (e) =>
68
+ e.isFile() &&
69
+ LOADABLE_EXT_RE.test(e.name) &&
70
+ !CONVENTION_FILE_RE.test(e.name) &&
71
+ !e.name.endsWith(".d.ts"),
72
+ )
73
+ .map((e) => e.name)
74
+ .sort((a, b) => a.localeCompare(b));
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
47
80
  /**
48
81
  * Load a single policy file into the globalThis custom hooks registry.
49
82
  * Does NOT clear the registry — caller is responsible for that.
@@ -135,9 +168,25 @@ export interface LoadAllResult {
135
168
  * Each file is loaded independently (fail-open per file).
136
169
  * Convention hooks are tagged with __conventionScope so the handler can build scoped prefixes.
137
170
  */
171
+ /**
172
+ * Warn once per directory about script files the naming convention will skip.
173
+ * Fires on the hook path, so it must stay cheap (one readdir) and must never
174
+ * throw — a warning is not worth failing a tool call over.
175
+ */
176
+ function warnSkippedPolicyFiles(dir: string, scope: "project" | "user"): void {
177
+ const skipped = findSkippedPolicyFiles(dir);
178
+ if (skipped.length === 0) return;
179
+ const suggest = (n: string) => n.replace(/\.(js|mjs|ts)$/, "-policies.$1");
180
+ hookLogWarn(
181
+ `${scope} policies: ${skipped.length} file(s) in ${dir} are NOT loaded — ` +
182
+ `the convention only loads names ending in "policies.js|mjs|ts". ` +
183
+ skipped.map((n) => `${n} → rename to ${suggest(n)}`).join("; "),
184
+ );
185
+ }
186
+
138
187
  export async function loadAllCustomHooks(
139
188
  customPoliciesPath: string | undefined,
140
- opts?: { sessionCwd?: string },
189
+ opts?: { sessionCwd?: string; customPoliciesEnabled?: boolean },
141
190
  ): Promise<LoadAllResult> {
142
191
  clearCustomHooks();
143
192
 
@@ -145,6 +194,13 @@ export async function loadAllCustomHooks(
145
194
 
146
195
  const projectRoot = findProjectConfigDir(opts?.sessionCwd ?? process.cwd());
147
196
 
197
+ // Convention discovery can be switched off from config without renaming or
198
+ // deleting anything. Absent means on, so dropping a file in works with no
199
+ // config at all and upgrades don't silently disable anyone's rules. An
200
+ // explicit `customPoliciesPath` is NOT gated by this: that one was named on
201
+ // purpose, so switching off *discovery* shouldn't silently drop it too.
202
+ const conventionEnabled = opts?.customPoliciesEnabled !== false;
203
+
148
204
  // 1. Explicit customPoliciesPath (existing behavior)
149
205
  if (customPoliciesPath) {
150
206
  const absPath = isAbsolute(customPoliciesPath)
@@ -161,7 +217,8 @@ export async function loadAllCustomHooks(
161
217
 
162
218
  // 2. Project convention: {projectRoot}/.failproofai/policies/*policies.{js,mjs,ts}
163
219
  const projectDir = resolve(projectRoot, ".failproofai", "policies");
164
- const projectFiles = discoverPolicyFiles(projectDir);
220
+ if (conventionEnabled) warnSkippedPolicyFiles(projectDir, "project");
221
+ const projectFiles = conventionEnabled ? discoverPolicyFiles(projectDir) : [];
165
222
  for (const file of projectFiles) {
166
223
  const hooksBefore = getCustomHooks().length;
167
224
  await loadSingleFile(file, { conventionScope: "project" });
@@ -177,7 +234,8 @@ export async function loadAllCustomHooks(
177
234
 
178
235
  // 3. User convention: ~/.failproofai/policies/*policies.{js,mjs,ts}
179
236
  const userDir = resolve(homedir(), ".failproofai", "policies");
180
- const userFiles = discoverPolicyFiles(userDir);
237
+ if (conventionEnabled) warnSkippedPolicyFiles(userDir, "user");
238
+ const userFiles = conventionEnabled ? discoverPolicyFiles(userDir) : [];
181
239
  for (const file of userFiles) {
182
240
  const hooksBefore = getCustomHooks().length;
183
241
  await loadSingleFile(file, { conventionScope: "user" });
@@ -194,7 +252,15 @@ export async function loadAllCustomHooks(
194
252
  const allHooks = getCustomHooks();
195
253
  const conventionCount = allHooks.length - hooksBeforeConvention;
196
254
 
197
- if (projectFiles.length > 0 || userFiles.length > 0) {
255
+ if (!conventionEnabled) {
256
+ // Say so. Silently loading nothing is exactly the failure this feature set
257
+ // exists to remove — a user who forgot the flag would see their policies
258
+ // do nothing with no way to tell why.
259
+ hookLogInfo(
260
+ "convention policies: DISABLED via customPoliciesEnabled:false — " +
261
+ "no .failproofai/policies/ files loaded",
262
+ );
263
+ } else if (projectFiles.length > 0 || userFiles.length > 0) {
198
264
  hookLogInfo(
199
265
  `convention policies: ${projectFiles.length} project file(s), ${userFiles.length} user file(s), ${conventionCount} hook(s)`,
200
266
  );
@@ -243,7 +243,10 @@ export async function handleHookEvent(
243
243
  registerBuiltinPolicies(config.enabledPolicies);
244
244
 
245
245
  // Load and register custom hooks (layer 2, after builtins)
246
- const loadResult = await loadAllCustomHooks(config.customPoliciesPath, { sessionCwd: session.cwd });
246
+ const loadResult = await loadAllCustomHooks(config.customPoliciesPath, {
247
+ sessionCwd: session.cwd,
248
+ customPoliciesEnabled: config.customPoliciesEnabled,
249
+ });
247
250
  const customHooksList = loadResult.hooks;
248
251
  const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
249
252
 
@@ -85,4 +85,12 @@ export interface HooksConfig {
85
85
  llm?: LlmConfig;
86
86
  policyParams?: Record<string, Record<string, unknown>>;
87
87
  customPoliciesPath?: string;
88
+ /**
89
+ * Turn off convention-discovered custom policies (`.failproofai/policies/`)
90
+ * without deleting or renaming the files. Absent means enabled — the default
91
+ * has to be "on" so that dropping a policy file in works with no config at
92
+ * all, and so existing installs don't lose their custom rules on upgrade.
93
+ * Only ever written explicitly as `false`.
94
+ */
95
+ customPoliciesEnabled?: boolean;
88
96
  }