failproofai 0.0.2-beta.6 → 0.0.2-beta.8

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 (162) hide show
  1. package/.next/standalone/.claude/settings.json +316 -0
  2. package/.next/standalone/.failproofai/policies/workflow-policies.mjs +62 -0
  3. package/.next/standalone/.failproofai/policies-config.json +39 -0
  4. package/.next/standalone/.next/BUILD_ID +1 -1
  5. package/.next/standalone/.next/build-manifest.json +5 -5
  6. package/.next/standalone/.next/prerender-manifest.json +3 -3
  7. package/.next/standalone/.next/required-server-files.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  9. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  16. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  20. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  25. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  27. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  30. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  31. package/.next/standalone/.next/server/app/index.html +1 -1
  32. package/.next/standalone/.next/server/app/index.rsc +15 -15
  33. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  34. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  35. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  36. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  37. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  38. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  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/build-manifest.json +2 -2
  43. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  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/build-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/chunks/[root-of-the-server]__02nt~6d._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_05pz9._._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0u_n1xe._.js → [root-of-the-server]__0.t2266._.js} +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okos0k._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0epc5zr._.js → [root-of-the-server]__0pjorff._.js} +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +8 -9
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0a_7sdg.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0ef3uwk.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0j79~gv.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0pbja1x.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0r6o0i2.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_11y81~_.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_12or2kf.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_0mebn66._.js +1 -1
  83. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  84. package/.next/standalone/.next/server/pages/404.html +2 -2
  85. package/.next/standalone/.next/server/pages/500.html +1 -1
  86. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  88. package/.next/standalone/.next/static/chunks/{0efsuf1p-k4qe.js → 04xfyqyhdxbxz.js} +1 -1
  89. package/.next/standalone/.next/static/chunks/{17p200_z1ivz4.js → 07g0rbtaux_1t.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{031pa5~qfzt~_.js → 09e7drilkf1sn.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{0tood0~87-mm8.js → 0a_xh94bt.y0j.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{0rvepm.~uvks4.js → 0j752uotyfvjh.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{0wkzaq-8sxss7.js → 0qi0ubup__3pj.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{0kbfx4p.g9wnr.js → 0xyvis4r_y.8o.js} +2 -2
  95. package/.next/standalone/.next/static/chunks/{0jqg886bw85_6.js → 0zfyfi1suoteq.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{0_tx_~f8pi3d7.js → 121a-0zn-knuy.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{turbopack-0uc5y~g6h.n7-.js → turbopack-0r26pc8h0y_-e.js} +1 -1
  98. package/.next/standalone/CHANGELOG.md +88 -0
  99. package/.next/standalone/CLAUDE.md +14 -0
  100. package/.next/standalone/README.md +20 -3
  101. package/.next/standalone/bin/failproofai.mjs +5 -0
  102. package/.next/standalone/bun.lock +31 -63
  103. package/.next/standalone/dist/cli.mjs +268 -73
  104. package/.next/standalone/docs/built-in-policies.mdx +19 -3
  105. package/.next/standalone/docs/configuration.mdx +46 -0
  106. package/.next/standalone/docs/custom-policies.mdx +65 -7
  107. package/.next/standalone/docs/docs.json +3 -3
  108. package/.next/standalone/examples/convention-policies/security-policies.mjs +40 -0
  109. package/.next/standalone/examples/convention-policies/workflow-policies.mjs +41 -0
  110. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  111. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  112. package/.next/standalone/node_modules/next/dist/compiled/jsonwebtoken/index.js +2 -2
  113. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  114. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  115. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
  116. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  117. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  118. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +7 -2
  119. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  120. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  121. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  122. package/.next/standalone/node_modules/next/dist/server/render.js +20 -19
  123. package/.next/standalone/node_modules/next/dist/shared/lib/errors/canary-only-config-error.js +1 -1
  124. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  125. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  126. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  127. package/.next/standalone/node_modules/next/package.json +15 -15
  128. package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
  129. package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
  130. package/.next/standalone/node_modules/react/package.json +1 -1
  131. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
  132. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
  133. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
  134. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
  135. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
  136. package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
  137. package/.next/standalone/node_modules/react-dom/package.json +2 -2
  138. package/.next/standalone/package.json +1 -1
  139. package/.next/standalone/server.js +1 -1
  140. package/.next/standalone/src/hooks/builtin-policies.ts +70 -18
  141. package/.next/standalone/src/hooks/custom-hooks-loader.ts +165 -21
  142. package/.next/standalone/src/hooks/handler.ts +32 -6
  143. package/.next/standalone/src/hooks/hooks-config.ts +47 -2
  144. package/.next/standalone/src/hooks/llm-client.ts +2 -2
  145. package/.next/standalone/src/hooks/loader-utils.ts +4 -4
  146. package/.next/standalone/src/hooks/manager.ts +57 -14
  147. package/.next/standalone/src/hooks/policy-evaluator.ts +35 -17
  148. package/README.md +20 -3
  149. package/bin/failproofai.mjs +5 -0
  150. package/dist/cli.mjs +268 -73
  151. package/package.json +1 -1
  152. package/src/hooks/builtin-policies.ts +70 -18
  153. package/src/hooks/custom-hooks-loader.ts +165 -21
  154. package/src/hooks/handler.ts +32 -6
  155. package/src/hooks/hooks-config.ts +47 -2
  156. package/src/hooks/llm-client.ts +2 -2
  157. package/src/hooks/loader-utils.ts +4 -4
  158. package/src/hooks/manager.ts +57 -14
  159. package/src/hooks/policy-evaluator.ts +35 -17
  160. /package/.next/standalone/.next/static/{gDMch26rYN-bU-9f6ftKR → itedhTSyIDln6TUf41j5X}/_buildManifest.js +0 -0
  161. /package/.next/standalone/.next/static/{gDMch26rYN-bU-9f6ftKR → itedhTSyIDln6TUf41j5X}/_clientMiddlewareManifest.js +0 -0
  162. /package/.next/standalone/.next/static/{gDMch26rYN-bU-9f6ftKR → itedhTSyIDln6TUf41j5X}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.2-beta.6",
3
+ "version": "0.0.2-beta.8",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -168,6 +168,50 @@ function getCurrentBranch(cwd: string): string | null {
168
168
  }
169
169
  }
170
170
 
171
+ function getHeadSha(cwd: string): string | null {
172
+ try {
173
+ const sha = execSync("git rev-parse HEAD", {
174
+ cwd,
175
+ encoding: "utf8",
176
+ timeout: 3000,
177
+ }).trim();
178
+ return sha || null;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ interface CiCheck {
185
+ name: string;
186
+ status: string;
187
+ conclusion: string;
188
+ }
189
+
190
+ /** Fetch third-party check runs (non-GitHub-Actions) for a commit via the Checks API. */
191
+ function getThirdPartyCheckRuns(cwd: string, sha: string): CiCheck[] {
192
+ try {
193
+ const json = execFileSync(
194
+ "gh",
195
+ [
196
+ "api",
197
+ `repos/{owner}/{repo}/commits/${sha}/check-runs`,
198
+ "--jq",
199
+ '.check_runs | map(select(.app.slug != "github-actions")) | map({name: .name, status: .status, conclusion: (.conclusion // "")})',
200
+ ],
201
+ {
202
+ cwd,
203
+ encoding: "utf8",
204
+ timeout: 15000,
205
+ },
206
+ ).trim();
207
+
208
+ if (!json || json === "[]") return [];
209
+ return JSON.parse(json) as CiCheck[];
210
+ } catch {
211
+ return [];
212
+ }
213
+ }
214
+
171
215
  /**
172
216
  * Check if a command matches an allow pattern using token-by-token comparison.
173
217
  * The "*" token is a wildcard. Extra command tokens beyond the pattern are allowed,
@@ -1013,27 +1057,35 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
1013
1057
  const branch = getCurrentBranch(cwd);
1014
1058
  if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping CI check.");
1015
1059
 
1016
- const runsJson = execFileSync(
1017
- "gh",
1018
- ["run", "list", "--branch", branch, "--limit", "5", "--json", "status,conclusion,name"],
1019
- {
1020
- cwd,
1021
- encoding: "utf8",
1022
- timeout: 15000,
1023
- },
1024
- ).trim();
1060
+ // 1. GitHub Actions workflow runs
1061
+ let workflowRuns: CiCheck[] = [];
1062
+ try {
1063
+ const runsJson = execFileSync(
1064
+ "gh",
1065
+ ["run", "list", "--branch", branch, "--limit", "5", "--json", "status,conclusion,name"],
1066
+ { cwd, encoding: "utf8", timeout: 15000 },
1067
+ ).trim();
1025
1068
 
1026
- if (!runsJson || runsJson === "[]") return allow(`No CI runs found for branch "${branch}".`);
1069
+ if (runsJson && runsJson !== "[]") {
1070
+ workflowRuns = JSON.parse(runsJson) as CiCheck[];
1071
+ }
1072
+ } catch {
1073
+ // fail-open for workflow runs; continue to check third-party checks
1074
+ }
1075
+
1076
+ // 2. Third-party check runs (CodeRabbit, SonarCloud, Codecov, etc.)
1077
+ let thirdPartyChecks: CiCheck[] = [];
1078
+ const sha = getHeadSha(cwd);
1079
+ if (sha) {
1080
+ thirdPartyChecks = getThirdPartyCheckRuns(cwd, sha);
1081
+ }
1027
1082
 
1028
- const runs = JSON.parse(runsJson) as Array<{
1029
- status: string;
1030
- conclusion: string;
1031
- name: string;
1032
- }>;
1083
+ // 3. Merge all checks
1084
+ const allChecks = [...workflowRuns, ...thirdPartyChecks];
1033
1085
 
1034
- if (runs.length === 0) return allow(`No CI runs found for branch "${branch}".`);
1086
+ if (allChecks.length === 0) return allow(`No CI runs found for branch "${branch}".`);
1035
1087
 
1036
- const failing = runs.filter(
1088
+ const failing = allChecks.filter(
1037
1089
  (r) => r.status === "completed" && r.conclusion !== "success" && r.conclusion !== "skipped",
1038
1090
  );
1039
1091
  if (failing.length > 0) {
@@ -1043,7 +1095,7 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
1043
1095
  );
1044
1096
  }
1045
1097
 
1046
- const pending = runs.filter(
1098
+ const pending = allChecks.filter(
1047
1099
  (r) => r.status === "in_progress" || r.status === "queued" || r.status === "waiting",
1048
1100
  );
1049
1101
  if (pending.length > 0) {
@@ -1,39 +1,51 @@
1
1
  /**
2
- * Loads a user-authored hooks.js file with ESM import rewriting.
2
+ * Loads user-authored policy files with ESM import rewriting.
3
3
  * Supports transitive local imports and `import { ... } from 'failproofai'`.
4
4
  *
5
+ * Two loading modes:
6
+ * 1. Explicit: a single file via `customPoliciesPath` in policies-config.json
7
+ * 2. Convention: auto-discovered *policies.{js,mjs,ts} files from
8
+ * .failproofai/policies/ at project and user level (git-hooks style)
9
+ *
5
10
  * Fail-open: any error (file not found, syntax error, import failure) is logged
6
- * and results in an empty hook list. Builtins continue running normally.
11
+ * and results in an empty hook list for that file. Builtins continue normally.
7
12
  */
8
- import { resolve, isAbsolute } from "node:path";
9
- import { existsSync } from "node:fs";
13
+ import { resolve, isAbsolute, basename } from "node:path";
14
+ import { existsSync, readdirSync } from "node:fs";
10
15
  import { pathToFileURL } from "node:url";
11
- import { hookLogWarn, hookLogError } from "./hook-logger";
16
+ import { homedir } from "node:os";
17
+ import { hookLogWarn, hookLogError, hookLogInfo } from "./hook-logger";
12
18
  import { getCustomHooks, clearCustomHooks } from "./custom-hooks-registry";
13
19
  import { findDistIndex, rewriteFileTree, TMP_SUFFIX, cleanupTmpFiles } from "./loader-utils";
14
20
  import type { CustomHook } from "./policy-types";
15
21
 
16
22
  const LOADING_KEY = "__FAILPROOFAI_LOADING_HOOKS__";
17
23
 
18
- export async function loadCustomHooks(
19
- customPoliciesPath: string | undefined,
20
- opts?: { strict?: boolean },
21
- ): Promise<CustomHook[]> {
22
- if (!customPoliciesPath) return [];
24
+ /** Regex matching convention policy filenames: *policies.{js,mjs,ts} */
25
+ const CONVENTION_FILE_RE = /policies\.(js|mjs|ts)$/;
23
26
 
24
- const absPath = isAbsolute(customPoliciesPath)
25
- ? customPoliciesPath
26
- : resolve(process.cwd(), customPoliciesPath);
27
-
28
- if (!existsSync(absPath)) {
29
- if (opts?.strict) throw new Error(`Custom hooks file not found: ${absPath}`);
30
- hookLogWarn(`customPoliciesPath not found: ${absPath}`);
27
+ /**
28
+ * Scan a directory for convention policy files (*policies.{js,mjs,ts}).
29
+ * Returns sorted absolute paths. Returns [] if the directory doesn't exist.
30
+ */
31
+ export function discoverPolicyFiles(dir: string): string[] {
32
+ if (!existsSync(dir)) return [];
33
+ try {
34
+ const entries = readdirSync(dir, { withFileTypes: true });
35
+ return entries
36
+ .filter((e) => e.isFile() && CONVENTION_FILE_RE.test(e.name))
37
+ .sort((a, b) => a.name.localeCompare(b.name))
38
+ .map((e) => resolve(dir, e.name));
39
+ } catch {
31
40
  return [];
32
41
  }
42
+ }
33
43
 
34
- // Clear registry before loading so each invocation starts fresh
35
- clearCustomHooks();
36
-
44
+ /**
45
+ * Load a single policy file into the globalThis custom hooks registry.
46
+ * Does NOT clear the registry — caller is responsible for that.
47
+ */
48
+ async function loadSingleFile(absPath: string, opts?: { strict?: boolean }): Promise<void> {
37
49
  const g = globalThis as Record<string, unknown>;
38
50
  g[LOADING_KEY] = true;
39
51
 
@@ -51,11 +63,143 @@ export async function loadCustomHooks(
51
63
  const msg = err instanceof Error ? err.message : String(err);
52
64
  if (opts?.strict) throw new Error(`Failed to load custom hooks from ${absPath}: ${msg}`);
53
65
  hookLogError(`failed to load custom hooks from ${absPath}: ${msg}`);
54
- return [];
55
66
  } finally {
56
67
  g[LOADING_KEY] = false;
57
68
  await cleanupTmpFiles(tmpFiles);
58
69
  }
70
+ }
71
+
72
+ /**
73
+ * Load a single explicit custom hooks file (legacy API).
74
+ * Clears the registry, loads the file, returns registered hooks.
75
+ */
76
+ export async function loadCustomHooks(
77
+ customPoliciesPath: string | undefined,
78
+ opts?: { strict?: boolean; sessionCwd?: string },
79
+ ): Promise<CustomHook[]> {
80
+ if (!customPoliciesPath) return [];
81
+
82
+ const absPath = isAbsolute(customPoliciesPath)
83
+ ? customPoliciesPath
84
+ : resolve(opts?.sessionCwd ?? process.cwd(), customPoliciesPath);
85
+
86
+ if (!existsSync(absPath)) {
87
+ if (opts?.strict) throw new Error(`Custom hooks file not found: ${absPath}`);
88
+ hookLogWarn(`customPoliciesPath not found: ${absPath}`);
89
+ return [];
90
+ }
59
91
 
92
+ clearCustomHooks();
93
+ await loadSingleFile(absPath, opts);
60
94
  return getCustomHooks();
61
95
  }
96
+
97
+ /** Source metadata for a loaded convention policy file. */
98
+ export interface ConventionSource {
99
+ scope: "project" | "user";
100
+ file: string;
101
+ hookNames: string[];
102
+ }
103
+
104
+ /** Result of loadAllCustomHooks with source metadata. */
105
+ export interface LoadAllResult {
106
+ hooks: CustomHook[];
107
+ conventionSources: ConventionSource[];
108
+ }
109
+
110
+ /**
111
+ * Load ALL custom hooks: explicit customPoliciesPath + convention-discovered files.
112
+ *
113
+ * Load order:
114
+ * 1. Explicit customPoliciesPath (if configured)
115
+ * 2. Project convention: {cwd}/.failproofai/policies/*policies.{js,mjs,ts} (alphabetical)
116
+ * 3. User convention: ~/.failproofai/policies/*policies.{js,mjs,ts} (alphabetical)
117
+ *
118
+ * Each file is loaded independently (fail-open per file).
119
+ * Convention hooks are tagged with __conventionScope so the handler can build scoped prefixes.
120
+ */
121
+ export async function loadAllCustomHooks(
122
+ customPoliciesPath: string | undefined,
123
+ opts?: { sessionCwd?: string },
124
+ ): Promise<LoadAllResult> {
125
+ clearCustomHooks();
126
+
127
+ const conventionSources: ConventionSource[] = [];
128
+
129
+ // 1. Explicit customPoliciesPath (existing behavior)
130
+ if (customPoliciesPath) {
131
+ const absPath = isAbsolute(customPoliciesPath)
132
+ ? customPoliciesPath
133
+ : resolve(opts?.sessionCwd ?? process.cwd(), customPoliciesPath);
134
+ if (existsSync(absPath)) {
135
+ await loadSingleFile(absPath);
136
+ } else {
137
+ hookLogWarn(`customPoliciesPath not found: ${absPath}`);
138
+ }
139
+ }
140
+
141
+ const hooksBeforeConvention = getCustomHooks().length;
142
+
143
+ // 2. Project convention: {cwd}/.failproofai/policies/*policies.{js,mjs,ts}
144
+ const projectDir = resolve(opts?.sessionCwd ?? process.cwd(), ".failproofai", "policies");
145
+ const projectFiles = discoverPolicyFiles(projectDir);
146
+ for (const file of projectFiles) {
147
+ const hooksBefore = getCustomHooks().length;
148
+ await loadSingleFile(file);
149
+ const newHooks = getCustomHooks().slice(hooksBefore);
150
+ if (newHooks.length > 0) {
151
+ conventionSources.push({
152
+ scope: "project",
153
+ file: basename(file),
154
+ hookNames: newHooks.map((h) => h.name),
155
+ });
156
+ }
157
+ }
158
+
159
+ // 3. User convention: ~/.failproofai/policies/*policies.{js,mjs,ts}
160
+ const userDir = resolve(homedir(), ".failproofai", "policies");
161
+ const userFiles = discoverPolicyFiles(userDir);
162
+ for (const file of userFiles) {
163
+ const hooksBefore = getCustomHooks().length;
164
+ await loadSingleFile(file);
165
+ const newHooks = getCustomHooks().slice(hooksBefore);
166
+ if (newHooks.length > 0) {
167
+ conventionSources.push({
168
+ scope: "user",
169
+ file: basename(file),
170
+ hookNames: newHooks.map((h) => h.name),
171
+ });
172
+ }
173
+ }
174
+
175
+ const allHooks = getCustomHooks();
176
+ const conventionCount = allHooks.length - hooksBeforeConvention;
177
+
178
+ if (projectFiles.length > 0 || userFiles.length > 0) {
179
+ hookLogInfo(
180
+ `convention policies: ${projectFiles.length} project file(s), ${userFiles.length} user file(s), ${conventionCount} hook(s)`,
181
+ );
182
+ }
183
+
184
+ // Tag convention hooks with their scope so the handler can build scoped prefixes.
185
+ // Build a name→scope map from conventionSources, then tag by object reference
186
+ // to avoid mis-tagging an explicit custom hook that shares the same name.
187
+ const hookNameToScope = new Map<string, string>();
188
+ for (const source of conventionSources) {
189
+ for (const name of source.hookNames) {
190
+ hookNameToScope.set(name, source.scope);
191
+ }
192
+ }
193
+ const conventionHookRefs = new Set<CustomHook>();
194
+ for (const hook of allHooks.slice(hooksBeforeConvention)) {
195
+ conventionHookRefs.add(hook);
196
+ }
197
+ for (const hook of allHooks) {
198
+ if (conventionHookRefs.has(hook)) {
199
+ (hook as CustomHook & { __conventionScope?: string }).__conventionScope =
200
+ hookNameToScope.get(hook.name) ?? "project";
201
+ }
202
+ }
203
+
204
+ return { hooks: allHooks, conventionSources };
205
+ }
@@ -11,7 +11,8 @@ import { readMergedHooksConfig } from "./hooks-config";
11
11
  import { registerBuiltinPolicies } from "./builtin-policies";
12
12
  import { evaluatePolicies } from "./policy-evaluator";
13
13
  import { clearPolicies, registerPolicy } from "./policy-registry";
14
- import { loadCustomHooks } from "./custom-hooks-loader";
14
+ import { loadAllCustomHooks } from "./custom-hooks-loader";
15
+ import type { CustomHook } from "./policy-types";
15
16
  import { persistHookActivity } from "./hook-activity-store";
16
17
  import { trackHookEvent } from "./hook-telemetry";
17
18
  import { getInstanceId } from "../../lib/telemetry-id";
@@ -71,9 +72,15 @@ export async function handleHookEvent(eventType: string): Promise<number> {
71
72
  registerBuiltinPolicies(config.enabledPolicies);
72
73
 
73
74
  // Load and register custom hooks (layer 2, after builtins)
74
- const customHooksList = await loadCustomHooks(config.customPoliciesPath);
75
+ const loadResult = await loadAllCustomHooks(config.customPoliciesPath, { sessionCwd: session.cwd });
76
+ const customHooksList = loadResult.hooks;
77
+ const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
78
+
75
79
  for (const hook of customHooksList) {
76
80
  const hookName = hook.name;
81
+ const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
82
+ const isConvention = !!conventionScope;
83
+ const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom";
77
84
  const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
78
85
  try {
79
86
  const result = await Promise.race([
@@ -86,17 +93,19 @@ export async function handleHookEvent(eventType: string): Promise<number> {
86
93
  } catch (err) {
87
94
  const msg = err instanceof Error ? err.message : String(err);
88
95
  const isTimeout = msg === "timeout";
89
- hookLogWarn(`custom hook "${hookName}" failed: ${msg}`);
96
+ hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`);
90
97
  void trackHookEvent(getInstanceId(), "custom_hook_error", {
91
98
  hook_name: hookName,
92
99
  error_type: isTimeout ? "timeout" : "exception",
93
100
  event_type: eventType,
101
+ is_convention_policy: isConvention,
102
+ convention_scope: conventionScope ?? null,
94
103
  });
95
104
  return { decision: "allow" };
96
105
  }
97
106
  };
98
107
  registerPolicy(
99
- `custom/${hookName}`,
108
+ `${prefix}/${hookName}`,
100
109
  hook.description ?? "",
101
110
  fn,
102
111
  hook.match ?? {},
@@ -113,7 +122,18 @@ export async function handleHookEvent(eventType: string): Promise<number> {
113
122
  });
114
123
  }
115
124
 
116
- hookLogInfo(`event=${eventType} policies=${config.enabledPolicies.length} custom=${customHooksList.length}`);
125
+ // Fire telemetry for convention-based policy discovery
126
+ if (loadResult.conventionSources.length > 0) {
127
+ void trackHookEvent(getInstanceId(), "convention_policies_loaded", {
128
+ event_type: eventType,
129
+ project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length,
130
+ user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length,
131
+ convention_hook_count: conventionHookNames.size,
132
+ convention_hook_names: [...conventionHookNames],
133
+ });
134
+ }
135
+
136
+ hookLogInfo(`event=${eventType} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`);
117
137
 
118
138
  // Evaluate policies
119
139
  const result = await evaluatePolicies(eventType as HookEventType, parsed, session, config);
@@ -152,8 +172,12 @@ export async function handleHookEvent(eventType: string): Promise<number> {
152
172
  if (result.decision === "deny" || result.decision === "instruct") {
153
173
  try {
154
174
  const isCustomHook = result.policyName?.startsWith("custom/") ?? false;
175
+ const isConventionPolicy = result.policyName?.startsWith(".failproofai-") ?? false;
176
+ const conventionScope = isConventionPolicy
177
+ ? result.policyName!.match(/^\.failproofai-(project|user)\//)?.[1] ?? null
178
+ : null;
155
179
  const hasCustomParams =
156
- !isCustomHook && !!(result.policyName && config.policyParams?.[result.policyName]);
180
+ !isCustomHook && !isConventionPolicy && !!(result.policyName && config.policyParams?.[result.policyName]);
157
181
  const paramKeysOverridden = hasCustomParams
158
182
  ? Object.keys(config.policyParams![result.policyName!])
159
183
  : [];
@@ -164,6 +188,8 @@ export async function handleHookEvent(eventType: string): Promise<number> {
164
188
  policy_name: result.policyName,
165
189
  decision: result.decision,
166
190
  is_custom_hook: isCustomHook,
191
+ is_convention_policy: isConventionPolicy,
192
+ convention_scope: conventionScope,
167
193
  has_custom_params: hasCustomParams,
168
194
  param_keys_overridden: paramKeysOverridden,
169
195
  });
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
5
5
  import { resolve, dirname } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import type { HooksConfig } from "./policy-types";
8
+ import type { HookScope } from "./types";
8
9
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
9
10
 
10
11
  function readConfigAt(path: string): Partial<HooksConfig> {
@@ -100,14 +101,58 @@ export function writeHooksConfig(config: HooksConfig): void {
100
101
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
101
102
  }
102
103
 
104
+ /**
105
+ * Resolve the policies-config path for a specific scope.
106
+ */
107
+ export function getConfigPathForScope(scope: HookScope, cwd?: string): string {
108
+ const base = cwd ? resolve(cwd) : process.cwd();
109
+ switch (scope) {
110
+ case "user":
111
+ return resolve(homedir(), ".failproofai", "policies-config.json");
112
+ case "project":
113
+ return resolve(base, ".failproofai", "policies-config.json");
114
+ case "local":
115
+ return resolve(base, ".failproofai", "policies-config.local.json");
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Read hooks config from a single specific scope (not merged).
121
+ */
122
+ export function readScopedHooksConfig(scope: HookScope, cwd?: string): HooksConfig {
123
+ const configPath = getConfigPathForScope(scope, cwd);
124
+ if (!existsSync(configPath)) {
125
+ return { enabledPolicies: [] };
126
+ }
127
+ try {
128
+ const raw = readFileSync(configPath, "utf8");
129
+ return JSON.parse(raw) as HooksConfig;
130
+ } catch (err) {
131
+ hookLogWarn(`failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
132
+ return { enabledPolicies: [] };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Write hooks config to the scope-appropriate path.
138
+ */
139
+ export function writeScopedHooksConfig(config: HooksConfig, scope: HookScope, cwd?: string): void {
140
+ const configPath = getConfigPathForScope(scope, cwd);
141
+ const dir = dirname(configPath);
142
+ if (!existsSync(dir)) {
143
+ mkdirSync(dir, { recursive: true });
144
+ }
145
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
146
+ }
147
+
103
148
  export interface ResolvedLlmConfig {
104
149
  baseUrl: string;
105
150
  apiKey: string;
106
151
  model: string;
107
152
  }
108
153
 
109
- export function readLlmConfig(): ResolvedLlmConfig | null {
110
- const config = readHooksConfig();
154
+ export function readLlmConfig(cwd?: string): ResolvedLlmConfig | null {
155
+ const config = readMergedHooksConfig(cwd);
111
156
  const baseUrl =
112
157
  process.env.FAILPROOFAI_LLM_BASE_URL ?? config.llm?.baseUrl ?? "https://api.openai.com/v1";
113
158
  const apiKey = process.env.FAILPROOFAI_LLM_API_KEY ?? config.llm?.apiKey;
@@ -30,9 +30,9 @@ export interface ChatCompletionResponse {
30
30
 
31
31
  export async function chatCompletion(
32
32
  messages: ChatMessage[],
33
- options?: ChatCompletionOptions,
33
+ options?: ChatCompletionOptions & { cwd?: string },
34
34
  ): Promise<ChatCompletionResponse> {
35
- const config = readLlmConfig();
35
+ const config = readLlmConfig(options?.cwd);
36
36
  if (!config) {
37
37
  throw new Error(
38
38
  "No LLM API key configured. Set FAILPROOFAI_LLM_API_KEY or configure llm.apiKey in policies-config.json",
@@ -71,7 +71,8 @@ export async function resolveLocalImport(
71
71
 
72
72
  /**
73
73
  * Create an ESM shim that re-exports from the CJS dist module.
74
- * Includes all public API exports: createApp, customHooks, allow, deny, instruct.
74
+ * Exports the full public API of failproofai: customPolicies, allow, deny, instruct,
75
+ * getCustomHooks, clearCustomHooks.
75
76
  */
76
77
  export async function createEsmShim(
77
78
  distIndex: string,
@@ -80,10 +81,9 @@ export async function createEsmShim(
80
81
  const shimPath = distIndex + ".__failproofai_esm_shim__.mjs";
81
82
  const shimCode = [
82
83
  `import _cjs from '${distUrl}';`,
83
- `export const createApp = _cjs.createApp;`,
84
- `export const getQueueCondition = _cjs.getQueueCondition;`,
85
- `export const clearQueueCondition = _cjs.clearQueueCondition;`,
86
84
  `export const customPolicies = _cjs.customPolicies;`,
85
+ `export const getCustomHooks = _cjs.getCustomHooks;`,
86
+ `export const clearCustomHooks = _cjs.clearCustomHooks;`,
87
87
  `export const allow = _cjs.allow;`,
88
88
  `export const deny = _cjs.deny;`,
89
89
  `export const instruct = _cjs.instruct;`,
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { execSync } from "node:child_process";
5
5
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
- import { resolve, dirname } from "node:path";
6
+ import { resolve, dirname, basename } from "node:path";
7
7
  import { homedir, platform, arch, release, hostname } from "node:os";
8
8
  import {
9
9
  HOOK_EVENT_TYPES,
@@ -15,10 +15,10 @@ import {
15
15
  type ClaudeSettings,
16
16
  } from "./types";
17
17
  import { promptPolicySelection } from "./install-prompt";
18
- import { readHooksConfig, writeHooksConfig, readMergedHooksConfig } from "./hooks-config";
18
+ import { readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig } from "./hooks-config";
19
19
  import type { HooksConfig } from "./policy-types";
20
20
  import { BUILTIN_POLICIES } from "./builtin-policies";
21
- import { loadCustomHooks } from "./custom-hooks-loader";
21
+ import { loadCustomHooks, discoverPolicyFiles } from "./custom-hooks-loader";
22
22
  import { trackHookEvent } from "./hook-telemetry";
23
23
  import { getInstanceId, hashToId } from "../../lib/telemetry-id";
24
24
  import { CliError } from "../cli-error";
@@ -203,7 +203,7 @@ export async function installHooks(
203
203
  const binaryPath = resolveFailproofaiBinary();
204
204
 
205
205
  // Capture existing config before overwriting (used for telemetry diff)
206
- const previousConfig = readHooksConfig();
206
+ const previousConfig = readScopedHooksConfig(scope, cwd);
207
207
  const previousEnabled = new Set(previousConfig.enabledPolicies);
208
208
 
209
209
  let selectedPolicies: string[];
@@ -251,7 +251,7 @@ export async function installHooks(
251
251
  `\nValidated ${validatedHooks.length} custom hook(s): ${validatedHooks.map((h) => h.name).join(", ")}`,
252
252
  );
253
253
  }
254
- writeHooksConfig(configToWrite);
254
+ writeScopedHooksConfig(configToWrite, scope, cwd);
255
255
  console.log(`\nEnabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}`);
256
256
  if (removeCustomHooks) {
257
257
  console.log("Custom hooks path cleared.");
@@ -355,18 +355,21 @@ export async function installHooks(
355
355
  * @param opts.betaOnly — set to true when removing only beta policies (adds beta_only flag to telemetry)
356
356
  */
357
357
  export async function removeHooks(policyNames?: string[], scope: HookScope | "all" = "user", cwd?: string, opts?: { betaOnly?: boolean; source?: string; removeCustomHooks?: boolean }): Promise<void> {
358
+ // Resolve the effective config scope ("all" falls back to "user" for config reads/writes)
359
+ const configScope: HookScope = scope === "all" ? "user" : scope;
360
+
358
361
  // Clear custom hooks path if requested
359
362
  if (opts?.removeCustomHooks) {
360
- const config = readHooksConfig();
363
+ const config = readScopedHooksConfig(configScope, cwd);
361
364
  delete config.customPoliciesPath;
362
- writeHooksConfig(config);
365
+ writeScopedHooksConfig(config, configScope, cwd);
363
366
  console.log("Custom hooks path cleared.");
364
367
  }
365
368
 
366
369
  // Remove specific policies from config (keep hooks installed)
367
370
  if (policyNames && policyNames.length > 0 && !(policyNames.length === 1 && policyNames[0] === "all")) {
368
371
  validatePolicyNames(policyNames);
369
- const config = readHooksConfig();
372
+ const config = readScopedHooksConfig(configScope, cwd);
370
373
  const removeSet = new Set(policyNames);
371
374
  const remaining = config.enabledPolicies.filter((p) => !removeSet.has(p));
372
375
  const notEnabled = policyNames.filter((p) => !config.enabledPolicies.includes(p));
@@ -382,7 +385,7 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al
382
385
  enabledPolicies: remaining,
383
386
  ...(filteredParams && Object.keys(filteredParams).length > 0 ? { policyParams: filteredParams } : {}),
384
387
  };
385
- writeHooksConfig(updatedConfig);
388
+ writeScopedHooksConfig(updatedConfig, configScope, cwd);
386
389
 
387
390
  // Telemetry: track policy-only removal from config
388
391
  try {
@@ -410,7 +413,7 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al
410
413
  }
411
414
 
412
415
  // Capture enabled policies before clearing (used for accurate telemetry below)
413
- const configBeforeRemoval = readHooksConfig();
416
+ const configBeforeRemoval = readScopedHooksConfig(configScope, cwd);
414
417
 
415
418
  // Remove all failproofai hooks from Claude Code settings
416
419
  const scopesToRemove: HookScope[] = scope === "all" ? [...HOOK_SCOPES] : [scope];
@@ -472,10 +475,19 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al
472
475
  }
473
476
 
474
477
  // Clear policy config when removing from all scopes, or when no hooks remain in any scope
475
- if (scope === "all" || !HOOK_SCOPES.some((s) => hooksInstalledInSettings(s, cwd))) {
476
- const existingForClear = readHooksConfig();
477
- const { customPoliciesPath: _drop, policyParams: _dropParams, ...restClear } = existingForClear;
478
- writeHooksConfig({ ...restClear, enabledPolicies: [] });
478
+ if (scope === "all") {
479
+ // Clear config across all three scopes
480
+ for (const s of HOOK_SCOPES) {
481
+ const existing = readScopedHooksConfig(s, cwd);
482
+ if (existing.enabledPolicies.length > 0 || existing.customPoliciesPath || existing.policyParams) {
483
+ const { customPoliciesPath: _drop, policyParams: _dropParams, ...rest } = existing;
484
+ writeScopedHooksConfig({ ...rest, enabledPolicies: [] }, s, cwd);
485
+ }
486
+ }
487
+ } else if (!HOOK_SCOPES.some((s) => hooksInstalledInSettings(s, cwd))) {
488
+ const existing = readScopedHooksConfig(configScope, cwd);
489
+ const { customPoliciesPath: _drop, policyParams: _dropParams, ...rest } = existing;
490
+ writeScopedHooksConfig({ ...rest, enabledPolicies: [] }, configScope, cwd);
479
491
  }
480
492
  }
481
493
 
@@ -638,4 +650,35 @@ export async function listHooks(cwd?: string): Promise<void> {
638
650
  }
639
651
  console.log();
640
652
  }
653
+
654
+ // Convention Policies section (.failproofai/policies/*policies.{js,mjs,ts})
655
+ const base = cwd ? resolve(cwd) : process.cwd();
656
+ const conventionDirs: { label: string; dir: string }[] = [
657
+ { label: "Project", dir: resolve(base, ".failproofai", "policies") },
658
+ { label: "User", dir: resolve(homedir(), ".failproofai", "policies") },
659
+ ];
660
+
661
+ for (const { label, dir } of conventionDirs) {
662
+ const files = discoverPolicyFiles(dir);
663
+ if (files.length === 0) continue;
664
+
665
+ console.log(`\n \u2500\u2500 Convention Policies \u2014 ${label} (${dir}) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
666
+ for (const file of files) {
667
+ try {
668
+ const hooks = await loadCustomHooks(file);
669
+ if (hooks.length === 0) {
670
+ const filename = basename(file);
671
+ console.log(` \x1B[31m\u2717\x1B[0m ${filename.padEnd(nameColWidth)}\x1B[31mfailed to load\x1B[0m`);
672
+ } else {
673
+ const filename = basename(file);
674
+ const hookSummary = hooks.map((h) => h.name).join(", ");
675
+ console.log(` \x1B[32m\u2713\x1B[0m ${filename.padEnd(nameColWidth)}${hooks.length} hook(s): ${hookSummary}`);
676
+ }
677
+ } catch {
678
+ const filename = basename(file);
679
+ console.log(` \x1B[31m\u2717\x1B[0m ${filename.padEnd(nameColWidth)}\x1B[31merror\x1B[0m`);
680
+ }
681
+ }
682
+ console.log();
683
+ }
641
684
  }