failproofai 0.0.15-beta.0 → 0.0.15

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 (107) 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/audit/page/server-reference-manifest.json +2 -2
  27. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  29. package/.next/standalone/.next/server/app/index.html +1 -1
  30. package/.next/standalone/.next/server/app/index.rsc +14 -14
  31. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  33. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  34. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  35. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  36. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  37. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  39. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +20 -8
  40. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  53. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  54. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1is3glr._.js +1 -1
  55. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  56. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +3 -3
  57. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +2 -2
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__04o-kw7._.js → [root-of-the-server]__06b4trb._.js} +2 -2
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0fopait._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +10 -10
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/ssr/app_audit_loading_tsx_1j1kc6j._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0w82veb.js → node_modules_html-to-image_es_index_0hs_5mh.js} +1 -1
  74. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  75. package/.next/standalone/.next/server/pages/404.html +1 -1
  76. package/.next/standalone/.next/server/pages/500.html +1 -1
  77. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  78. package/.next/standalone/.next/server/server-reference-manifest.json +22 -10
  79. package/.next/standalone/.next/static/chunks/{31c084gsop1bb.js → 0kf9j0pf_j9-0.js} +1 -1
  80. package/.next/standalone/.next/static/chunks/{1qzsbft-jtnf8.js → 1-ng45379-ztb.js} +1 -1
  81. package/.next/standalone/.next/static/chunks/11qhtpk2cvoxv.js +1 -0
  82. package/.next/standalone/.next/static/chunks/{1_20f96f1253b.js → 1_v8b8mxhk25s.js} +1 -1
  83. package/.next/standalone/.next/static/chunks/{312ihvhxeuoi_.js → 2f4mxqkkoa_7d.js} +1 -1
  84. package/.next/standalone/.next/static/chunks/{2xbkqlma_-61k.js → 2geh8s7d3ead-.js} +1 -1
  85. package/.next/standalone/.next/static/chunks/{3q9y67-fobgpc.js → 2h7vw-ojgr9fz.js} +1 -1
  86. package/.next/standalone/.next/static/chunks/33rm-y-i4uzvv.js +6 -0
  87. package/.next/standalone/.next/static/chunks/{1j2xm011_aoae.js → 33u59vf_8xpd-.js} +1 -1
  88. package/.next/standalone/.next/static/chunks/{2y7acsw0--8xf.js → 3m8wmvlhsy1mx.js} +1 -1
  89. package/.next/standalone/app/actions/get-hooks-config.ts +44 -12
  90. package/.next/standalone/app/actions/update-hooks-config.ts +38 -1
  91. package/.next/standalone/app/policies/hooks-client.tsx +38 -14
  92. package/.next/standalone/package.json +1 -1
  93. package/.next/standalone/server.js +1 -1
  94. package/bin/failproofai.mjs +14 -13
  95. package/dist/cli.mjs +172 -97
  96. package/package.json +1 -1
  97. package/src/hooks/custom-hooks-loader.ts +33 -10
  98. package/src/hooks/handler.ts +8 -2
  99. package/src/hooks/hooks-config.ts +31 -5
  100. package/src/hooks/manager.ts +115 -50
  101. package/src/hooks/policy-types.ts +5 -0
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1i21qhv._.js +0 -3
  103. package/.next/standalone/.next/static/chunks/0hn30_j22wpp_.js +0 -6
  104. package/.next/standalone/.next/static/chunks/3060a-0fayz96.js +0 -1
  105. /package/.next/standalone/.next/static/{QFmU1xD4R9N_HVfwccP6A → ePKMmXXfXxThNsxwv1EiF}/_buildManifest.js +0 -0
  106. /package/.next/standalone/.next/static/{QFmU1xD4R9N_HVfwccP6A → ePKMmXXfXxThNsxwv1EiF}/_clientMiddlewareManifest.js +0 -0
  107. /package/.next/standalone/.next/static/{QFmU1xD4R9N_HVfwccP6A → ePKMmXXfXxThNsxwv1EiF}/_ssgManifest.js +0 -0
package/dist/cli.mjs CHANGED
@@ -19,7 +19,7 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
19
19
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
 
21
21
  // package.json
22
- var version2 = "0.0.15-beta.0";
22
+ var version2 = "0.0.15";
23
23
  var init_package = () => {};
24
24
 
25
25
  // src/posthog-key.ts
@@ -669,6 +669,9 @@ var init_hook_logger = __esm(() => {
669
669
  import { readFileSync, writeFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, statSync as statSync2 } from "node:fs";
670
670
  import { resolve, dirname } from "node:path";
671
671
  import { homedir as homedir2 } from "node:os";
672
+ function configuredCustomPolicyPaths(config) {
673
+ return config.customPoliciesPaths ?? (config.customPoliciesPath ? [config.customPoliciesPath] : []);
674
+ }
672
675
  function readConfigAt(path2) {
673
676
  if (!existsSync2(path2))
674
677
  return {};
@@ -719,12 +722,20 @@ function readMergedHooksConfig(cwd) {
719
722
  }
720
723
  }
721
724
  }
722
- const customPoliciesPath = project.customPoliciesPath ?? local.customPoliciesPath ?? global_.customPoliciesPath;
725
+ const customPoliciesPaths = [project, local, global_].map((scope) => scope.customPoliciesPaths !== undefined ? scope.customPoliciesPaths : scope.customPoliciesPath !== undefined ? [scope.customPoliciesPath] : undefined).find((paths) => paths !== undefined);
726
+ const disabledCustomPolicies = new Set([
727
+ ...project.disabledCustomPolicies ?? [],
728
+ ...local.disabledCustomPolicies ?? [],
729
+ ...global_.disabledCustomPolicies ?? []
730
+ ]);
731
+ const customPoliciesEnabled = project.customPoliciesEnabled ?? local.customPoliciesEnabled ?? global_.customPoliciesEnabled;
723
732
  const llm = project.llm ?? local.llm ?? global_.llm;
724
733
  return {
725
734
  enabledPolicies: [...enabledSet],
726
735
  ...Object.keys(mergedParams).length > 0 ? { policyParams: mergedParams } : {},
727
- ...customPoliciesPath !== undefined ? { customPoliciesPath } : {},
736
+ ...customPoliciesPaths !== undefined ? { customPoliciesPaths } : {},
737
+ ...disabledCustomPolicies.size ? { disabledCustomPolicies: [...disabledCustomPolicies] } : {},
738
+ ...customPoliciesEnabled !== undefined ? { customPoliciesEnabled } : {},
728
739
  ...llm !== undefined ? { llm } : {}
729
740
  };
730
741
  }
@@ -3619,6 +3630,12 @@ async function loadCustomHooks(customPoliciesPath, opts) {
3619
3630
  await loadSingleFile(absPath, opts);
3620
3631
  return getCustomHooks();
3621
3632
  }
3633
+ function customPolicyId(file, name) {
3634
+ return `custom:${file}:${name}`;
3635
+ }
3636
+ function conventionPolicyId(scope, file, name) {
3637
+ return `convention:${scope}:${file}:${name}`;
3638
+ }
3622
3639
  function warnSkippedPolicyFiles(dir, scope) {
3623
3640
  const skipped = findSkippedPolicyFiles(dir);
3624
3641
  if (skipped.length === 0)
@@ -3626,19 +3643,25 @@ function warnSkippedPolicyFiles(dir, scope) {
3626
3643
  const suggest = (n) => n.replace(/\.(js|mjs|ts)$/, "-policies.$1");
3627
3644
  hookLogWarn(`${scope} policies: ${skipped.length} file(s) in ${dir} are NOT loaded — ` + `the convention only loads names ending in "policies.js|mjs|ts". ` + skipped.map((n) => `${n} → rename to ${suggest(n)}`).join("; "));
3628
3645
  }
3629
- async function loadAllCustomHooks(customPoliciesPath, opts) {
3646
+ async function loadAllCustomHooks(customPoliciesPaths, opts) {
3630
3647
  clearCustomHooks();
3631
3648
  const conventionSources = [];
3632
3649
  const projectRoot = findProjectConfigDir(opts?.sessionCwd ?? process.cwd());
3633
3650
  const conventionEnabled = opts?.customPoliciesEnabled !== false;
3634
3651
  const loadedPaths = new Set;
3635
- if (customPoliciesPath) {
3636
- const absPath = isAbsolute(customPoliciesPath) ? customPoliciesPath : resolve4(projectRoot, customPoliciesPath);
3652
+ for (const customPoliciesPath of typeof customPoliciesPaths === "string" ? [customPoliciesPaths] : customPoliciesPaths ?? []) {
3653
+ const absPath = resolve4(projectRoot, customPoliciesPath);
3637
3654
  if (existsSync3(absPath)) {
3638
- loadedPaths.add(absPath);
3639
- await loadSingleFile(absPath);
3655
+ if (!loadedPaths.has(absPath)) {
3656
+ loadedPaths.add(absPath);
3657
+ const hooksBefore = getCustomHooks().length;
3658
+ await loadSingleFile(absPath);
3659
+ for (const hook of getCustomHooks().slice(hooksBefore)) {
3660
+ hook.__policyId = customPolicyId(absPath, hook.name);
3661
+ }
3662
+ }
3640
3663
  } else {
3641
- hookLogWarn(`customPoliciesPath not found: ${absPath}`);
3664
+ hookLogWarn(`custom policy path not found: ${absPath}`);
3642
3665
  }
3643
3666
  }
3644
3667
  const hooksBeforeConvention = getCustomHooks().length;
@@ -3651,6 +3674,9 @@ async function loadAllCustomHooks(customPoliciesPath, opts) {
3651
3674
  const hooksBefore = getCustomHooks().length;
3652
3675
  await loadSingleFile(file, { conventionScope: "project" });
3653
3676
  const newHooks = getCustomHooks().slice(hooksBefore);
3677
+ for (const hook of newHooks) {
3678
+ hook.__policyId = conventionPolicyId("project", basename(file), hook.name);
3679
+ }
3654
3680
  if (newHooks.length > 0) {
3655
3681
  conventionSources.push({
3656
3682
  scope: "project",
@@ -3668,6 +3694,9 @@ async function loadAllCustomHooks(customPoliciesPath, opts) {
3668
3694
  const hooksBefore = getCustomHooks().length;
3669
3695
  await loadSingleFile(file, { conventionScope: "user" });
3670
3696
  const newHooks = getCustomHooks().slice(hooksBefore);
3697
+ for (const hook of newHooks) {
3698
+ hook.__policyId = conventionPolicyId("user", basename(file), hook.name);
3699
+ }
3671
3700
  if (newHooks.length > 0) {
3672
3701
  conventionSources.push({
3673
3702
  scope: "user",
@@ -8521,13 +8550,17 @@ async function handleHookEvent(eventType, cli = "claude") {
8521
8550
  const config = readMergedHooksConfig(session.cwd);
8522
8551
  clearPolicies();
8523
8552
  registerBuiltinPolicies(config.enabledPolicies);
8524
- const loadResult = await loadAllCustomHooks(config.customPoliciesPath, {
8553
+ const loadResult = await loadAllCustomHooks(config.customPoliciesPaths ?? config.customPoliciesPath, {
8525
8554
  sessionCwd: session.cwd,
8526
8555
  customPoliciesEnabled: config.customPoliciesEnabled
8527
8556
  });
8528
8557
  const customHooksList = loadResult.hooks;
8558
+ const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
8529
8559
  const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
8530
8560
  for (const hook of customHooksList) {
8561
+ const policyId = hook.__policyId;
8562
+ if (policyId && disabledCustomPolicies.has(policyId))
8563
+ continue;
8531
8564
  const hookName = hook.name;
8532
8565
  const conventionScope = hook.__conventionScope;
8533
8566
  const isConvention = !!conventionScope;
@@ -18282,34 +18315,45 @@ async function installHooksImpl(policyNames, scope = "user", cwd, includeBeta =
18282
18315
  const configToWrite = { ...previousConfig, enabledPolicies: selectedPolicies };
18283
18316
  if (removeCustomHooks) {
18284
18317
  delete configToWrite.customPoliciesPath;
18285
- } else if (customPoliciesPath) {
18286
- configToWrite.customPoliciesPath = resolve12(customPoliciesPath);
18287
- let validatedHooks = [];
18288
- try {
18289
- validatedHooks = await loadCustomHooks(configToWrite.customPoliciesPath, { strict: true });
18290
- } catch (err) {
18291
- const msg = err instanceof Error ? err.message : String(err);
18292
- try {
18293
- await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
18294
- scope,
18295
- error_type: /not found/i.test(msg) ? "file_not_found" : "load_error"
18296
- });
18297
- } catch {}
18298
- console.error(`Error: ${msg}`);
18299
- process.exit(1);
18300
- }
18301
- if (validatedHooks.length === 0) {
18318
+ delete configToWrite.customPoliciesPaths;
18319
+ } else if (customPoliciesPath && (typeof customPoliciesPath === "string" || customPoliciesPath.length > 0)) {
18320
+ const incoming = (typeof customPoliciesPath === "string" ? [customPoliciesPath] : customPoliciesPath).map((path3) => resolve12(path3));
18321
+ const carried = (previousConfig.customPoliciesPaths ?? (previousConfig.customPoliciesPath ? [previousConfig.customPoliciesPath] : [])).map((path3) => resolve12(path3)).filter((path3) => {
18322
+ if (existsSync12(path3))
18323
+ return true;
18324
+ console.log(`Dropping custom policies path (file no longer exists): ${path3}`);
18325
+ return false;
18326
+ });
18327
+ configToWrite.customPoliciesPaths = replace ? [...new Set(incoming)] : [...new Set([...carried, ...incoming])];
18328
+ delete configToWrite.customPoliciesPath;
18329
+ for (const path3 of incoming) {
18330
+ let validatedHooks = [];
18302
18331
  try {
18303
- await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
18304
- scope,
18305
- error_type: "no_hooks_registered"
18306
- });
18307
- } catch {}
18308
- console.error(`Error: no hooks registered in ${customPoliciesPath}. ` + `Make sure your file calls customPolicies.add(...) at least once.`);
18309
- process.exit(1);
18332
+ validatedHooks = await loadCustomHooks(path3, { strict: true });
18333
+ } catch (err) {
18334
+ const msg = err instanceof Error ? err.message : String(err);
18335
+ try {
18336
+ await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
18337
+ scope,
18338
+ error_type: /not found/i.test(msg) ? "file_not_found" : "load_error"
18339
+ });
18340
+ } catch {}
18341
+ console.error(`Error: ${msg}`);
18342
+ process.exit(1);
18343
+ }
18344
+ if (validatedHooks.length === 0) {
18345
+ try {
18346
+ await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
18347
+ scope,
18348
+ error_type: "no_hooks_registered"
18349
+ });
18350
+ } catch {}
18351
+ console.error(`Error: no hooks registered in ${path3}. ` + `Make sure your file calls customPolicies.add(...) at least once.`);
18352
+ process.exit(1);
18353
+ }
18354
+ console.log(`
18355
+ Validated ${validatedHooks.length} custom hook(s) from ${path3}: ${validatedHooks.map((h) => h.name).join(", ")}`);
18310
18356
  }
18311
- console.log(`
18312
- Validated ${validatedHooks.length} custom hook(s): ${validatedHooks.map((h) => h.name).join(", ")}`);
18313
18357
  }
18314
18358
  writeScopedHooksConfig(configToWrite, scope, cwd);
18315
18359
  console.log(`
@@ -18317,8 +18361,9 @@ Enabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}
18317
18361
  `);
18318
18362
  if (removeCustomHooks) {
18319
18363
  console.log("Custom hooks path cleared.");
18320
- } else if (configToWrite.customPoliciesPath) {
18321
- console.log(`Custom hooks path: ${configToWrite.customPoliciesPath}`);
18364
+ } else if (configToWrite.customPoliciesPaths?.length || configToWrite.customPoliciesPath) {
18365
+ const paths = configToWrite.customPoliciesPaths ?? [configToWrite.customPoliciesPath];
18366
+ console.log(`Custom hooks paths: ${paths.join(", ")}`);
18322
18367
  }
18323
18368
  const writtenSettingsPaths = [];
18324
18369
  for (const cliId of selectedClis) {
@@ -18361,7 +18406,7 @@ Enabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}
18361
18406
  arch: arch3(),
18362
18407
  os_release: release2(),
18363
18408
  hostname_hash: hashToId2(hostname3()),
18364
- has_custom_hooks_path: !!configToWrite.customPoliciesPath,
18409
+ has_custom_hooks_path: !!(configToWrite.customPoliciesPaths?.length || configToWrite.customPoliciesPath),
18365
18410
  has_policy_params: !!(configToWrite.policyParams && Object.keys(configToWrite.policyParams).length > 0),
18366
18411
  param_policy_names: configToWrite.policyParams ? Object.keys(configToWrite.policyParams) : [],
18367
18412
  command_format: scope === "project" ? "npx" : "absolute"
@@ -18416,6 +18461,7 @@ async function removeHooks(policyNames, scope = "user", cwd, opts) {
18416
18461
  if (opts?.removeCustomHooks) {
18417
18462
  const config = readScopedHooksConfig(configScope, cwd);
18418
18463
  delete config.customPoliciesPath;
18464
+ delete config.customPoliciesPaths;
18419
18465
  writeScopedHooksConfig(config, configScope, cwd);
18420
18466
  console.log("Custom hooks path cleared.");
18421
18467
  }
@@ -18521,20 +18567,21 @@ async function removeHooks(policyNames, scope = "user", cwd, opts) {
18521
18567
  if (scope === "all") {
18522
18568
  for (const s of HOOK_SCOPES) {
18523
18569
  const existing = readScopedHooksConfig(s, cwd);
18524
- if (existing.enabledPolicies.length > 0 || existing.customPoliciesPath || existing.policyParams) {
18525
- const { customPoliciesPath: _drop, policyParams: _dropParams, ...rest } = existing;
18570
+ if (existing.enabledPolicies.length > 0 || existing.customPoliciesPaths?.length || existing.customPoliciesPath || existing.policyParams) {
18571
+ const { customPoliciesPath: _drop, customPoliciesPaths: _dropMany, policyParams: _dropParams, ...rest } = existing;
18526
18572
  writeScopedHooksConfig({ ...rest, enabledPolicies: [] }, s, cwd);
18527
18573
  }
18528
18574
  }
18529
18575
  } else if (!HOOK_SCOPES.some((s) => hooksInstalledInSettings(s, cwd))) {
18530
18576
  const existing = readScopedHooksConfig(configScope, cwd);
18531
- const { customPoliciesPath: _drop, policyParams: _dropParams, ...rest } = existing;
18577
+ const { customPoliciesPath: _drop, customPoliciesPaths: _dropMany, policyParams: _dropParams, ...rest } = existing;
18532
18578
  writeScopedHooksConfig({ ...rest, enabledPolicies: [] }, configScope, cwd);
18533
18579
  }
18534
18580
  }
18535
18581
  async function listHooks(cwd) {
18536
18582
  const config = readMergedHooksConfig(cwd);
18537
18583
  const enabledSet = new Set(config.enabledPolicies);
18584
+ const disabledCustomSet = new Set(config.disabledCustomPolicies ?? []);
18538
18585
  const uniqueScopes = deduplicateScopes(HOOK_SCOPES, cwd);
18539
18586
  const installedScopes = uniqueScopes.filter((s) => hooksInstalledInSettings(s, cwd));
18540
18587
  const regularPolicies = BUILTIN_POLICIES.filter((p) => !p.beta);
@@ -18658,19 +18705,26 @@ Failproof AI Hook Policies
18658
18705
  } catch {}
18659
18706
  }
18660
18707
  }
18661
- if (config.customPoliciesPath) {
18708
+ const explicitPaths = configuredCustomPolicyPaths(config);
18709
+ if (explicitPaths.length > 0) {
18662
18710
  console.log(`
18663
- ── Custom Policies (${config.customPoliciesPath}) ───────────────────────`);
18664
- if (!existsSync12(config.customPoliciesPath)) {
18665
- console.log(` \x1B[31m✗ File not found: ${config.customPoliciesPath}\x1B[0m`);
18666
- } else {
18667
- const hooks = await loadCustomHooks(config.customPoliciesPath);
18711
+ ── Custom Policies ───────────────────────`);
18712
+ for (const path3 of explicitPaths) {
18713
+ const absPath = resolve12(findProjectConfigDir(cwd ?? process.cwd()), path3);
18714
+ console.log(` ${absPath}`);
18715
+ if (!existsSync12(absPath)) {
18716
+ console.log(` \x1B[31m✗ File not found: ${absPath}\x1B[0m`);
18717
+ continue;
18718
+ }
18719
+ const hooks = await loadCustomHooks(absPath);
18668
18720
  if (hooks.length === 0) {
18669
18721
  console.log(` \x1B[31m✗ ERR failed to load (check ~/.failproofai/logs/hooks.log)\x1B[0m`);
18670
18722
  } else {
18671
18723
  const descColWidth = nameColWidth;
18672
18724
  for (const hook of hooks) {
18673
- console.log(` \x1B[32m✓\x1B[0m ${hook.name.padEnd(descColWidth)}${hook.description ?? ""}`);
18725
+ const disabled = disabledCustomSet.has(`custom:${absPath}:${hook.name}`);
18726
+ const status = disabled ? "\x1B[2m OFF\x1B[0m" : "\x1B[32m✓ ON\x1B[0m";
18727
+ console.log(` ${status} ${hook.name.padEnd(descColWidth)}${hook.description ?? ""}`);
18674
18728
  }
18675
18729
  }
18676
18730
  }
@@ -18693,6 +18747,7 @@ Failproof AI Hook Policies
18693
18747
  if (files.length === 0)
18694
18748
  continue;
18695
18749
  const targets = dir === projectDir && dir === userDir ? ["project", "user"] : dir === projectDir ? ["project"] : ["user"];
18750
+ const policyScope = dir === projectDir ? "project" : "user";
18696
18751
  const record = (file, hooks) => {
18697
18752
  for (const t of targets)
18698
18753
  discovered[t].push({ file, hooks });
@@ -18708,8 +18763,14 @@ Failproof AI Hook Policies
18708
18763
  if (hooks.length === 0) {
18709
18764
  console.log(` \x1B[31m✗\x1B[0m ${filename.padEnd(colWidth)}\x1B[31mfailed to load\x1B[0m`);
18710
18765
  } else {
18711
- const hookSummary = hooks.map((h) => h.name).join(", ");
18712
- console.log(` \x1B[32m✓\x1B[0m ${filename.padEnd(colWidth)}${hooks.length} hook(s): ${hookSummary}`);
18766
+ const hookStates = hooks.map((hook) => ({
18767
+ hook,
18768
+ disabled: disabledCustomSet.has(`convention:${policyScope}:${filename}:${hook.name}`)
18769
+ }));
18770
+ const disabledCount = hookStates.filter((entry) => entry.disabled).length;
18771
+ const status = disabledCount === 0 ? "\x1B[32m✓ ON\x1B[0m" : disabledCount === hooks.length ? "\x1B[2m OFF\x1B[0m" : "\x1B[33m◐ MIXED\x1B[0m";
18772
+ const hookSummary = hookStates.map(({ hook, disabled }) => `${hook.name}${disabled ? " (OFF)" : ""}`).join(", ");
18773
+ console.log(` ${status} ${filename.padEnd(colWidth)}${hooks.length} hook(s): ${hookSummary}`);
18713
18774
  }
18714
18775
  } catch {
18715
18776
  const filename = basename4(file);
@@ -22130,6 +22191,7 @@ WHAT IT DOES
22130
22191
 
22131
22192
  // src/hooks/manager.ts
22132
22193
  import { execSync as execSync7 } from "node:child_process";
22194
+ import { existsSync as existsSync19 } from "node:fs";
22133
22195
  import { resolve as resolve16, basename as basename6 } from "node:path";
22134
22196
  import { homedir as homedir29, platform as platform5, arch as arch4, release as release3, hostname as hostname4 } from "node:os";
22135
22197
  function getSettingsPath2(scope, cwd) {
@@ -22236,34 +22298,45 @@ async function installHooksImpl2(policyNames, scope = "user", cwd, includeBeta =
22236
22298
  const configToWrite = { ...previousConfig, enabledPolicies: selectedPolicies };
22237
22299
  if (removeCustomHooks) {
22238
22300
  delete configToWrite.customPoliciesPath;
22239
- } else if (customPoliciesPath) {
22240
- configToWrite.customPoliciesPath = resolve16(customPoliciesPath);
22241
- let validatedHooks = [];
22242
- try {
22243
- validatedHooks = await loadCustomHooks(configToWrite.customPoliciesPath, { strict: true });
22244
- } catch (err) {
22245
- const msg = err instanceof Error ? err.message : String(err);
22246
- try {
22247
- await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
22248
- scope,
22249
- error_type: /not found/i.test(msg) ? "file_not_found" : "load_error"
22250
- });
22251
- } catch {}
22252
- console.error(`Error: ${msg}`);
22253
- process.exit(1);
22254
- }
22255
- if (validatedHooks.length === 0) {
22301
+ delete configToWrite.customPoliciesPaths;
22302
+ } else if (customPoliciesPath && (typeof customPoliciesPath === "string" || customPoliciesPath.length > 0)) {
22303
+ const incoming = (typeof customPoliciesPath === "string" ? [customPoliciesPath] : customPoliciesPath).map((path3) => resolve16(path3));
22304
+ const carried = (previousConfig.customPoliciesPaths ?? (previousConfig.customPoliciesPath ? [previousConfig.customPoliciesPath] : [])).map((path3) => resolve16(path3)).filter((path3) => {
22305
+ if (existsSync19(path3))
22306
+ return true;
22307
+ console.log(`Dropping custom policies path (file no longer exists): ${path3}`);
22308
+ return false;
22309
+ });
22310
+ configToWrite.customPoliciesPaths = replace ? [...new Set(incoming)] : [...new Set([...carried, ...incoming])];
22311
+ delete configToWrite.customPoliciesPath;
22312
+ for (const path3 of incoming) {
22313
+ let validatedHooks = [];
22256
22314
  try {
22257
- await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
22258
- scope,
22259
- error_type: "no_hooks_registered"
22260
- });
22261
- } catch {}
22262
- console.error(`Error: no hooks registered in ${customPoliciesPath}. ` + `Make sure your file calls customPolicies.add(...) at least once.`);
22263
- process.exit(1);
22315
+ validatedHooks = await loadCustomHooks(path3, { strict: true });
22316
+ } catch (err) {
22317
+ const msg = err instanceof Error ? err.message : String(err);
22318
+ try {
22319
+ await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
22320
+ scope,
22321
+ error_type: /not found/i.test(msg) ? "file_not_found" : "load_error"
22322
+ });
22323
+ } catch {}
22324
+ console.error(`Error: ${msg}`);
22325
+ process.exit(1);
22326
+ }
22327
+ if (validatedHooks.length === 0) {
22328
+ try {
22329
+ await trackHookEvent2(getInstanceId2(), "custom_policy_validation_failed", {
22330
+ scope,
22331
+ error_type: "no_hooks_registered"
22332
+ });
22333
+ } catch {}
22334
+ console.error(`Error: no hooks registered in ${path3}. ` + `Make sure your file calls customPolicies.add(...) at least once.`);
22335
+ process.exit(1);
22336
+ }
22337
+ console.log(`
22338
+ Validated ${validatedHooks.length} custom hook(s) from ${path3}: ${validatedHooks.map((h) => h.name).join(", ")}`);
22264
22339
  }
22265
- console.log(`
22266
- Validated ${validatedHooks.length} custom hook(s): ${validatedHooks.map((h) => h.name).join(", ")}`);
22267
22340
  }
22268
22341
  writeScopedHooksConfig(configToWrite, scope, cwd);
22269
22342
  console.log(`
@@ -22271,8 +22344,9 @@ Enabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}
22271
22344
  `);
22272
22345
  if (removeCustomHooks) {
22273
22346
  console.log("Custom hooks path cleared.");
22274
- } else if (configToWrite.customPoliciesPath) {
22275
- console.log(`Custom hooks path: ${configToWrite.customPoliciesPath}`);
22347
+ } else if (configToWrite.customPoliciesPaths?.length || configToWrite.customPoliciesPath) {
22348
+ const paths = configToWrite.customPoliciesPaths ?? [configToWrite.customPoliciesPath];
22349
+ console.log(`Custom hooks paths: ${paths.join(", ")}`);
22276
22350
  }
22277
22351
  const writtenSettingsPaths = [];
22278
22352
  for (const cliId of selectedClis) {
@@ -22315,7 +22389,7 @@ Enabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}
22315
22389
  arch: arch4(),
22316
22390
  os_release: release3(),
22317
22391
  hostname_hash: hashToId2(hostname4()),
22318
- has_custom_hooks_path: !!configToWrite.customPoliciesPath,
22392
+ has_custom_hooks_path: !!(configToWrite.customPoliciesPaths?.length || configToWrite.customPoliciesPath),
22319
22393
  has_policy_params: !!(configToWrite.policyParams && Object.keys(configToWrite.policyParams).length > 0),
22320
22394
  param_policy_names: configToWrite.policyParams ? Object.keys(configToWrite.policyParams) : [],
22321
22395
  command_format: scope === "project" ? "npx" : "absolute"
@@ -22714,7 +22788,7 @@ __export(exports_configure_wizard, {
22714
22788
  buildPresetChoices: () => buildPresetChoices,
22715
22789
  buildAgentChoices: () => buildAgentChoices
22716
22790
  });
22717
- import { existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, readFileSync as readFileSync16 } from "node:fs";
22791
+ import { existsSync as existsSync20, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, readFileSync as readFileSync16 } from "node:fs";
22718
22792
  import { homedir as homedir30 } from "node:os";
22719
22793
  import { resolve as resolve17, sep as sep5 } from "node:path";
22720
22794
  async function emit(event, props) {
@@ -22837,7 +22911,7 @@ function setCustomPoliciesEnabled(scope, cwd, enabled) {
22837
22911
  const path3 = getConfigPathForScope(scope, cwd);
22838
22912
  let config = {};
22839
22913
  try {
22840
- if (existsSync19(path3))
22914
+ if (existsSync20(path3))
22841
22915
  config = JSON.parse(readFileSync16(path3, "utf8"));
22842
22916
  } catch {
22843
22917
  return;
@@ -22902,7 +22976,7 @@ function firstRunMarkerPath() {
22902
22976
  return resolve17(homedir30(), ".failproofai", ".launcher-configured");
22903
22977
  }
22904
22978
  function hasSeenLauncher() {
22905
- return existsSync19(firstRunMarkerPath());
22979
+ return existsSync20(firstRunMarkerPath());
22906
22980
  }
22907
22981
  function markLauncherSeen() {
22908
22982
  try {
@@ -23056,7 +23130,7 @@ __export(exports_launch2, {
23056
23130
  launch: () => launch2
23057
23131
  });
23058
23132
  import { spawn as spawn3 } from "child_process";
23059
- import { realpathSync as realpathSync3, existsSync as existsSync20 } from "node:fs";
23133
+ import { realpathSync as realpathSync3, existsSync as existsSync21 } from "node:fs";
23060
23134
  import { resolve as resolve18, dirname as dirname9 } from "node:path";
23061
23135
  import { fileURLToPath as fileURLToPath3 } from "node:url";
23062
23136
  import { createInterface as createInterface3 } from "node:readline";
@@ -23074,7 +23148,7 @@ function launch2(mode) {
23074
23148
  cmd = "node";
23075
23149
  const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT ?? resolve18(dirname9(realpathSync3(fileURLToPath3(import.meta.url))), "..");
23076
23150
  const serverJsPath = resolve18(packageRoot, ".next/standalone/server.js");
23077
- if (!existsSync20(serverJsPath)) {
23151
+ if (!existsSync21(serverJsPath)) {
23078
23152
  let shadowMessage = null;
23079
23153
  try {
23080
23154
  const diag = diagnoseShadow({ selfPackageRoot: packageRoot, selfVersion: version2 });
@@ -23172,7 +23246,7 @@ import { realpathSync as realpathSync4 } from "fs";
23172
23246
  import { dirname as dirname10, resolve as resolve19 } from "path";
23173
23247
  import { fileURLToPath as fileURLToPath4 } from "url";
23174
23248
  // package.json
23175
- var version = "0.0.15-beta.0";
23249
+ var version = "0.0.15";
23176
23250
 
23177
23251
  // bin/failproofai.mjs
23178
23252
  if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
@@ -23266,7 +23340,7 @@ COMMANDS
23266
23340
  --scope user|project|local Config scope to write to (default: user)
23267
23341
  (Codex / Copilot / Cursor / OpenCode / Pi support user|project only)
23268
23342
  --beta Include beta policies
23269
- --custom, -c <path> Path to a JS file of custom policies
23343
+ --custom, -c <path> Custom policy file (repeat for multiple files)
23270
23344
 
23271
23345
  policies --uninstall, -u Disable policies or remove hooks
23272
23346
  [names...] Specific policy names to disable
@@ -23274,7 +23348,7 @@ COMMANDS
23274
23348
  Agent CLI(s) to uninstall from
23275
23349
  --scope user|project|local|all Config scope to remove from (default: user)
23276
23350
  --beta Remove only beta policies
23277
- --custom, -c Clear the customPoliciesPath from config
23351
+ --custom, -c Clear all explicit custom policy paths
23278
23352
 
23279
23353
  policies --help, -h Show this help for the policies command
23280
23354
 
@@ -23358,7 +23432,7 @@ OPTIONS (install)
23358
23432
  --scope user|project|local Config scope to write to (default: user)
23359
23433
  (Codex / Copilot / Cursor / OpenCode / Pi support user|project only)
23360
23434
  --beta Include beta policies
23361
- --custom, -c <path> Path to a JS file of custom policies
23435
+ --custom, -c <path> Custom policy file (repeat for multiple files)
23362
23436
  (skips interactive prompt; validates file first)
23363
23437
 
23364
23438
  OPTIONS (uninstall)
@@ -23367,7 +23441,7 @@ OPTIONS (uninstall)
23367
23441
  Agent CLI(s) to uninstall from
23368
23442
  --scope user|project|local|all Config scope to remove from (default: user)
23369
23443
  --beta Remove only beta policies
23370
- --custom, -c Clear the customPoliciesPath from config
23444
+ --custom, -c Clear all explicit custom policy paths
23371
23445
 
23372
23446
  EXAMPLES
23373
23447
  failproofai policies
@@ -23382,6 +23456,7 @@ EXAMPLES
23382
23456
  failproofai policies --install --cli devin --scope project
23383
23457
  failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose
23384
23458
  failproofai policies --install --custom ./my-policies.js
23459
+ failproofai policies --install --custom ./security.js --custom ./workflow.js
23385
23460
  failproofai policies -i -c ./my-policies.js
23386
23461
  failproofai policies --uninstall block-sudo
23387
23462
  failproofai policies --uninstall --cli codex
@@ -23406,9 +23481,9 @@ EXAMPLES
23406
23481
  if (scopeIdx >= 0 && !["user", "project", "local"].includes(scope)) {
23407
23482
  throw new CliError3(`Invalid scope: ${scope}. Valid values: user, project, local`);
23408
23483
  }
23409
- const customIdx = subArgs.includes("--custom") ? subArgs.indexOf("--custom") : subArgs.includes("-c") ? subArgs.indexOf("-c") : -1;
23410
- const customPoliciesPath = customIdx >= 0 ? subArgs[customIdx + 1] : undefined;
23411
- if (customIdx >= 0 && (!customPoliciesPath || customPoliciesPath.startsWith("-"))) {
23484
+ const customIdxs = subArgs.map((arg, index) => arg === "--custom" || arg === "-c" ? index : -1).filter((index) => index >= 0);
23485
+ const customPoliciesPaths = customIdxs.map((index) => subArgs[index + 1]);
23486
+ if (customPoliciesPaths.some((path3) => !path3 || path3.startsWith("-"))) {
23412
23487
  throw new CliError3(`Missing path after --custom/-c
23413
23488
  Usage: --custom <path> (e.g. --custom ./my-policies.js)`);
23414
23489
  }
@@ -23436,7 +23511,7 @@ Usage: --custom <path> (e.g. --custom ./my-policies.js)`);
23436
23511
  const consumedIdxs = new Set;
23437
23512
  if (scopeIdx >= 0)
23438
23513
  consumedIdxs.add(scopeIdx + 1);
23439
- if (customIdx >= 0)
23514
+ for (const customIdx of customIdxs)
23440
23515
  consumedIdxs.add(customIdx + 1);
23441
23516
  for (const i of cliConsumedIdxs)
23442
23517
  consumedIdxs.add(i);
@@ -23447,16 +23522,16 @@ Usage: --custom <path> (e.g. --custom ./my-policies.js)`);
23447
23522
  Run \`failproofai policies --help\` for usage.`);
23448
23523
  }
23449
23524
  const explicitPolicyNames = subArgs.filter((a, idx) => !a.startsWith("-") && !consumedIdxs.has(idx));
23450
- const policyNames = explicitPolicyNames.length > 0 ? explicitPolicyNames : customPoliciesPath !== undefined ? [] : undefined;
23525
+ const policyNames = explicitPolicyNames.length > 0 ? explicitPolicyNames : customPoliciesPaths.length > 0 ? [] : undefined;
23451
23526
  const cli = await resolveTargetClis2(cliFlagValues.length > 0 ? cliFlagValues : undefined, "install");
23452
- await installHooks3(policyNames, scope, undefined, includeBeta, undefined, customPoliciesPath, false, cli);
23527
+ await installHooks3(policyNames, scope, undefined, includeBeta, undefined, customPoliciesPaths.length > 0 ? customPoliciesPaths : undefined, false, cli);
23453
23528
  await track("cli_install_success", {
23454
23529
  scope,
23455
23530
  cli,
23456
23531
  cli_count: cli.length,
23457
23532
  explicit_policies: explicitPolicyNames.length > 0,
23458
23533
  include_beta: includeBeta,
23459
- has_custom_path: !!customPoliciesPath
23534
+ has_custom_path: customPoliciesPaths.length > 0
23460
23535
  });
23461
23536
  process.exit(0);
23462
23537
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.15-beta.0",
3
+ "version": "0.0.15",
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"
@@ -3,7 +3,7 @@
3
3
  * Supports transitive local imports and `import { ... } from 'failproofai'`.
4
4
  *
5
5
  * Two loading modes:
6
- * 1. Explicit: a single file via `customPoliciesPath` in policies-config.json
6
+ * 1. Explicit: files via `customPoliciesPaths` in policies-config.json
7
7
  * 2. Convention: auto-discovered *policies.{js,mjs,ts} files from
8
8
  * .failproofai/policies/ at project and user level (git-hooks style)
9
9
  *
@@ -177,6 +177,14 @@ export interface LoadAllResult {
177
177
  conventionSources: ConventionSource[];
178
178
  }
179
179
 
180
+ export function customPolicyId(file: string, name: string): string {
181
+ return `custom:${file}:${name}`;
182
+ }
183
+
184
+ export function conventionPolicyId(scope: "project" | "user", file: string, name: string): string {
185
+ return `convention:${scope}:${file}:${name}`;
186
+ }
187
+
180
188
  /**
181
189
  * Load ALL custom hooks: explicit customPoliciesPath + convention-discovered files.
182
190
  *
@@ -205,7 +213,7 @@ function warnSkippedPolicyFiles(dir: string, scope: "project" | "user"): void {
205
213
  }
206
214
 
207
215
  export async function loadAllCustomHooks(
208
- customPoliciesPath: string | undefined,
216
+ customPoliciesPaths: string | string[] | undefined,
209
217
  opts?: { sessionCwd?: string; customPoliciesEnabled?: boolean },
210
218
  ): Promise<LoadAllResult> {
211
219
  clearCustomHooks();
@@ -228,16 +236,25 @@ export async function loadAllCustomHooks(
228
236
  // convention passes.
229
237
  const loadedPaths = new Set<string>();
230
238
 
231
- // 1. Explicit customPoliciesPath (existing behavior)
232
- if (customPoliciesPath) {
233
- const absPath = isAbsolute(customPoliciesPath)
234
- ? customPoliciesPath
235
- : resolve(projectRoot, customPoliciesPath);
239
+ // 1. Explicit custom policy paths. Accept a string for callers/configs using
240
+ // the legacy singular form.
241
+ for (const customPoliciesPath of typeof customPoliciesPaths === "string"
242
+ ? [customPoliciesPaths]
243
+ : customPoliciesPaths ?? []) {
244
+ // resolve() also normalizes absolute paths, so aliases containing `.` or
245
+ // `..` share a dedup key with convention-discovered canonical paths.
246
+ const absPath = resolve(projectRoot, customPoliciesPath);
236
247
  if (existsSync(absPath)) {
237
- loadedPaths.add(absPath);
238
- await loadSingleFile(absPath);
248
+ if (!loadedPaths.has(absPath)) {
249
+ loadedPaths.add(absPath);
250
+ const hooksBefore = getCustomHooks().length;
251
+ await loadSingleFile(absPath);
252
+ for (const hook of getCustomHooks().slice(hooksBefore)) {
253
+ (hook as CustomHook & { __policyId?: string }).__policyId = customPolicyId(absPath, hook.name);
254
+ }
255
+ }
239
256
  } else {
240
- hookLogWarn(`customPoliciesPath not found: ${absPath}`);
257
+ hookLogWarn(`custom policy path not found: ${absPath}`);
241
258
  }
242
259
  }
243
260
 
@@ -260,6 +277,9 @@ export async function loadAllCustomHooks(
260
277
  const hooksBefore = getCustomHooks().length;
261
278
  await loadSingleFile(file, { conventionScope: "project" });
262
279
  const newHooks = getCustomHooks().slice(hooksBefore);
280
+ for (const hook of newHooks) {
281
+ (hook as CustomHook & { __policyId?: string }).__policyId = conventionPolicyId("project", basename(file), hook.name);
282
+ }
263
283
  if (newHooks.length > 0) {
264
284
  conventionSources.push({
265
285
  scope: "project",
@@ -294,6 +314,9 @@ export async function loadAllCustomHooks(
294
314
  const hooksBefore = getCustomHooks().length;
295
315
  await loadSingleFile(file, { conventionScope: "user" });
296
316
  const newHooks = getCustomHooks().slice(hooksBefore);
317
+ for (const hook of newHooks) {
318
+ (hook as CustomHook & { __policyId?: string }).__policyId = conventionPolicyId("user", basename(file), hook.name);
319
+ }
297
320
  if (newHooks.length > 0) {
298
321
  conventionSources.push({
299
322
  scope: "user",