failproofai 0.0.6-beta.4 → 0.0.6

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 (89) 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 +2 -2
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  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 +10 -10
  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/index.html +1 -1
  27. package/.next/standalone/.next/server/app/index.rsc +15 -15
  28. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  30. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  31. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  32. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  33. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  34. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  37. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  39. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  43. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  44. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +1 -1
  50. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  51. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  52. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
  53. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okos0k._.js +2 -2
  56. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__111.vxi._.js → [root-of-the-server]__0ow37ro._.js} +2 -2
  57. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0om-5pe._.js → [root-of-the-server]__0t3ka1q._.js} +2 -2
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +2 -2
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  61. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_0mebn66._.js +1 -1
  65. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  66. package/.next/standalone/.next/server/pages/404.html +2 -2
  67. package/.next/standalone/.next/server/pages/500.html +1 -1
  68. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  69. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  70. package/.next/standalone/.next/static/chunks/{0rcwkbh24w38b.js → 0-igg2k65fzo_.js} +1 -1
  71. package/.next/standalone/.next/static/chunks/{0o547jv-k_k35.js → 04iuhj_-h-21-.js} +1 -1
  72. package/.next/standalone/.next/static/chunks/{070orfsl6.xal.js → 061hxr2b-j.6q.js} +1 -1
  73. package/.next/standalone/.next/static/chunks/{17ne4p.1sw1jy.js → 0k5t-n0s8p2nr.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/{169_e4dq~1~b6.js → 0lq8ary5l4s8t.js} +1 -1
  75. package/.next/standalone/.next/static/chunks/{0pk2h2.mjxy.m.js → 0ubv3x~0zdd_w.js} +1 -1
  76. package/.next/standalone/.next/static/chunks/{02dqjyv6_9mhq.js → 0v23ca5xty1n~.js} +2 -2
  77. package/.next/standalone/.next/static/chunks/{140xx_tfr~lm_.js → 13cot7j99xkb~.js} +1 -1
  78. package/.next/standalone/failproofai-hq.gif +0 -0
  79. package/.next/standalone/package.json +1 -1
  80. package/.next/standalone/server.js +1 -1
  81. package/README.md +6 -2
  82. package/dist/cli.mjs +117 -11
  83. package/package.json +1 -1
  84. package/src/hooks/builtin-policies.ts +128 -3
  85. package/src/hooks/policy-evaluator.ts +31 -7
  86. package/src/hooks/policy-registry.ts +19 -2
  87. /package/.next/standalone/.next/static/{wOkJXoch1UmRAmyIuKZWc → my01WPjry7ohRUHyTaYp4}/_buildManifest.js +0 -0
  88. /package/.next/standalone/.next/static/{wOkJXoch1UmRAmyIuKZWc → my01WPjry7ohRUHyTaYp4}/_clientMiddlewareManifest.js +0 -0
  89. /package/.next/standalone/.next/static/{wOkJXoch1UmRAmyIuKZWc → my01WPjry7ohRUHyTaYp4}/_ssgManifest.js +0 -0
package/dist/cli.mjs CHANGED
@@ -198,6 +198,9 @@ function instruct(reason) {
198
198
  }
199
199
 
200
200
  // src/hooks/policy-registry.ts
201
+ function normalizePolicyName(name) {
202
+ return name.includes("/") ? name : `${DEFAULT_POLICY_NAMESPACE}/${name}`;
203
+ }
201
204
  function getIndexCache() {
202
205
  return globalThis[INDEX_CACHE_KEY];
203
206
  }
@@ -212,9 +215,10 @@ function getRegistry() {
212
215
  return g[REGISTRY_KEY];
213
216
  }
214
217
  function registerPolicy(name, description, fn, match, priority = 0) {
218
+ const canonical = normalizePolicyName(name);
215
219
  const registry = getRegistry();
216
- const idx = registry.findIndex((p) => p.name === name);
217
- const entry = { name, description, fn, match, priority };
220
+ const idx = registry.findIndex((p) => p.name === canonical);
221
+ const entry = { name: canonical, description, fn, match, priority };
218
222
  if (idx >= 0) {
219
223
  registry[idx] = entry;
220
224
  } else {
@@ -251,7 +255,7 @@ function clearPolicies() {
251
255
  g[REGISTRY_KEY] = [];
252
256
  setIndexCache(null);
253
257
  }
254
- var REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__", INDEX_CACHE_KEY = "__FAILPROOFAI_POLICY_INDEX_CACHE__";
258
+ var REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__", INDEX_CACHE_KEY = "__FAILPROOFAI_POLICY_INDEX_CACHE__", DEFAULT_POLICY_NAMESPACE = "exospherehost";
255
259
 
256
260
  // src/hooks/builtin-policies.ts
257
261
  import { resolve as resolve2, join as join2 } from "node:path";
@@ -1045,6 +1049,82 @@ function requirePrBeforeStop(ctx) {
1045
1049
  return allow("Could not check PR status, skipping.");
1046
1050
  }
1047
1051
  }
1052
+ function requireNoConflictsBeforeStop(ctx) {
1053
+ const cwd = ctx.session?.cwd;
1054
+ if (!cwd)
1055
+ return allow("No working directory available, skipping conflict check.");
1056
+ const branch = getCurrentBranch(cwd);
1057
+ if (!branch || branch === "HEAD")
1058
+ return allow("Detached HEAD, skipping conflict check.");
1059
+ const baseBranch = ctx.params?.baseBranch ?? "main";
1060
+ if (branch === baseBranch) {
1061
+ return allow(`On base branch "${baseBranch}", skipping conflict check.`);
1062
+ }
1063
+ let localSkipped = false;
1064
+ try {
1065
+ execFileSync("git", ["rev-parse", "--verify", `origin/${baseBranch}`], {
1066
+ cwd,
1067
+ encoding: "utf8",
1068
+ stdio: ["pipe", "pipe", "pipe"],
1069
+ timeout: 3000
1070
+ });
1071
+ const ahead = execFileSync("git", ["log", `origin/${baseBranch}..HEAD`, "--oneline"], { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
1072
+ if (!ahead) {
1073
+ localSkipped = true;
1074
+ } else {
1075
+ execFileSync("git", ["merge-tree", "--write-tree", "--name-only", `origin/${baseBranch}`, "HEAD"], { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 1e4 });
1076
+ }
1077
+ } catch (err) {
1078
+ const e = err;
1079
+ if (e.status === 1) {
1080
+ const out = (typeof e.stdout === "string" ? e.stdout : e.stdout?.toString("utf8") ?? "").trim();
1081
+ const lines = out.split(`
1082
+ `);
1083
+ const files = [];
1084
+ for (let i = 1;i < lines.length; i++) {
1085
+ const line = lines[i];
1086
+ if (line === "")
1087
+ break;
1088
+ files.push(line);
1089
+ }
1090
+ const fileList = files.length ? files.join(", ") : "one or more files";
1091
+ return deny(`Branch "${branch}" has merge conflicts with ${baseBranch} in: ${fileList}. ` + `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`);
1092
+ }
1093
+ localSkipped = true;
1094
+ }
1095
+ try {
1096
+ execSync("gh --version", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
1097
+ } catch {
1098
+ return allow(localSkipped ? "Local conflict check skipped and gh CLI not installed, skipping conflict check." : `Branch "${branch}" merges cleanly with ${baseBranch} locally (gh CLI not installed, PR mergeability not verified).`);
1099
+ }
1100
+ let prJson;
1101
+ try {
1102
+ prJson = execSync("gh pr view --json mergeable,number,url,state", {
1103
+ cwd,
1104
+ encoding: "utf8",
1105
+ stdio: ["pipe", "pipe", "pipe"],
1106
+ timeout: 15000
1107
+ }).trim();
1108
+ } catch {
1109
+ return allow(localSkipped ? "No pull request found for branch, skipping conflict check." : `Branch "${branch}" merges cleanly with ${baseBranch} locally (no PR to verify against).`);
1110
+ }
1111
+ let pr;
1112
+ try {
1113
+ pr = JSON.parse(prJson);
1114
+ } catch {
1115
+ return allow("Could not parse gh pr view output, skipping PR mergeability check.");
1116
+ }
1117
+ if (pr.state !== "OPEN") {
1118
+ return allow(`PR #${pr.number} is ${pr.state.toLowerCase()}; skipping conflict check.`);
1119
+ }
1120
+ if (pr.mergeable === "CONFLICTING") {
1121
+ return deny(`PR #${pr.number} has merge conflicts per GitHub (${pr.url}). ` + `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`);
1122
+ }
1123
+ if (pr.mergeable === "UNKNOWN") {
1124
+ return deny(`GitHub is still computing mergeability for PR #${pr.number} (${pr.url}). ` + `Wait ~10 seconds, then re-check with \`gh pr view --json mergeable\` before attempting to stop again.`);
1125
+ }
1126
+ return allow(`PR #${pr.number} merges cleanly per GitHub.`);
1127
+ }
1048
1128
  function requireCiGreenBeforeStop(ctx) {
1049
1129
  const cwd = ctx.session?.cwd;
1050
1130
  if (!cwd)
@@ -1091,9 +1171,9 @@ function requireCiGreenBeforeStop(ctx) {
1091
1171
  }
1092
1172
  }
1093
1173
  function registerBuiltinPolicies(enabledNames) {
1094
- const enabledSet = new Set(enabledNames);
1174
+ const enabledSet = new Set(enabledNames.map(normalizePolicyName));
1095
1175
  for (const policy of BUILTIN_POLICIES) {
1096
- if (enabledSet.has(policy.name)) {
1176
+ if (enabledSet.has(normalizePolicyName(policy.name))) {
1097
1177
  registerPolicy(policy.name, policy.description, policy.fn, policy.match);
1098
1178
  }
1099
1179
  }
@@ -1502,6 +1582,21 @@ var init_builtin_policies = __esm(() => {
1502
1582
  }
1503
1583
  }
1504
1584
  },
1585
+ {
1586
+ name: "require-no-conflicts-before-stop",
1587
+ description: "Require the current branch to merge cleanly with the base branch before Claude stops",
1588
+ fn: requireNoConflictsBeforeStop,
1589
+ match: { events: ["Stop"] },
1590
+ defaultEnabled: false,
1591
+ category: "Workflow",
1592
+ params: {
1593
+ baseBranch: {
1594
+ type: "string",
1595
+ description: "Base branch to check for conflicts against (default: main)",
1596
+ default: "main"
1597
+ }
1598
+ }
1599
+ },
1505
1600
  {
1506
1601
  name: "require-ci-green-before-stop",
1507
1602
  description: "Require CI checks to pass on the current branch before Claude stops",
@@ -1523,6 +1618,17 @@ function appendHint(baseReason, hint) {
1523
1618
  return normalizedHint;
1524
1619
  return `${base}. ${normalizedHint}`;
1525
1620
  }
1621
+ function getConfigParamsFor(config, canonicalName) {
1622
+ if (!config?.policyParams)
1623
+ return;
1624
+ const canonicalParams = config.policyParams[canonicalName];
1625
+ if (canonicalParams)
1626
+ return canonicalParams;
1627
+ const defaultPrefix = `${DEFAULT_POLICY_NAMESPACE}/`;
1628
+ if (!canonicalName.startsWith(defaultPrefix))
1629
+ return;
1630
+ return config.policyParams[canonicalName.slice(defaultPrefix.length)];
1631
+ }
1526
1632
  async function evaluatePolicies(eventType, payload, session, config) {
1527
1633
  const toolName = payload.tool_name;
1528
1634
  const toolInput = payload.tool_input;
@@ -1544,7 +1650,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1544
1650
  const schema = POLICY_PARAMS_MAP.get(policy.name);
1545
1651
  let ctx;
1546
1652
  if (schema) {
1547
- const userParams = config?.policyParams?.[policy.name] ?? {};
1653
+ const userParams = getConfigParamsFor(config, policy.name) ?? {};
1548
1654
  const resolvedParams = {};
1549
1655
  for (const [key, spec] of Object.entries(schema)) {
1550
1656
  resolvedParams[key] = key in userParams ? userParams[key] : spec.default;
@@ -1561,7 +1667,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1561
1667
  continue;
1562
1668
  }
1563
1669
  if (result.decision === "deny") {
1564
- const reason = appendHint(result.reason ?? `Blocked by policy: ${policy.name}`, config?.policyParams?.[policy.name]?.hint);
1670
+ const reason = appendHint(result.reason ?? `Blocked by policy: ${policy.name}`, getConfigParamsFor(config, policy.name)?.hint);
1565
1671
  hookLogInfo(`deny by "${policy.name}": ${reason}`);
1566
1672
  const displayTool = ctx.toolName ?? "unknown tool";
1567
1673
  if (eventType === "PreToolUse") {
@@ -1619,7 +1725,7 @@ You MUST complete the above action NOW. Do NOT ask the user for confirmation —
1619
1725
  };
1620
1726
  }
1621
1727
  if (result.decision === "instruct") {
1622
- const reason = appendHint(result.reason ?? `Instruction from policy: ${policy.name}`, config?.policyParams?.[policy.name]?.hint);
1728
+ const reason = appendHint(result.reason ?? `Instruction from policy: ${policy.name}`, getConfigParamsFor(config, policy.name)?.hint);
1623
1729
  instructEntries.push({ policyName: policy.name, reason });
1624
1730
  hookLogInfo(`instruct by "${policy.name}": ${reason}`);
1625
1731
  }
@@ -1678,7 +1784,7 @@ var POLICY_PARAMS_MAP;
1678
1784
  var init_policy_evaluator = __esm(() => {
1679
1785
  init_builtin_policies();
1680
1786
  init_hook_logger();
1681
- POLICY_PARAMS_MAP = new Map(BUILTIN_POLICIES.filter((p) => p.params).map((p) => [p.name, p.params]));
1787
+ POLICY_PARAMS_MAP = new Map(BUILTIN_POLICIES.filter((p) => p.params).map((p) => [normalizePolicyName(p.name), p.params]));
1682
1788
  });
1683
1789
 
1684
1790
  // src/hooks/custom-hooks-registry.ts
@@ -2065,7 +2171,7 @@ var init_hook_activity_store = __esm(() => {
2065
2171
  });
2066
2172
 
2067
2173
  // package.json
2068
- var version2 = "0.0.6-beta.4";
2174
+ var version2 = "0.0.6";
2069
2175
  var init_package = () => {};
2070
2176
 
2071
2177
  // src/posthog-key.ts
@@ -4378,7 +4484,7 @@ import { realpathSync as realpathSync2 } from "fs";
4378
4484
  import { dirname as dirname7, resolve as resolve8 } from "path";
4379
4485
  import { fileURLToPath as fileURLToPath2 } from "url";
4380
4486
  // package.json
4381
- var version = "0.0.6-beta.4";
4487
+ var version = "0.0.6";
4382
4488
 
4383
4489
  // bin/failproofai.mjs
4384
4490
  if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.6-beta.4",
3
+ "version": "0.0.6",
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"
@@ -7,7 +7,7 @@ import { execSync, execFileSync } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import type { BuiltinPolicyDefinition, PolicyContext, PolicyResult, PolicyParamsSchema } from "./policy-types";
9
9
  import { allow, deny, instruct } from "./policy-helpers";
10
- import { registerPolicy } from "./policy-registry";
10
+ import { normalizePolicyName, registerPolicy } from "./policy-registry";
11
11
  import { hookLogWarn } from "./hook-logger";
12
12
 
13
13
  function isClaudeInternalPath(resolved: string): boolean {
@@ -1185,6 +1185,114 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
1185
1185
  }
1186
1186
  }
1187
1187
 
1188
+ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
1189
+ const cwd = ctx.session?.cwd;
1190
+ if (!cwd) return allow("No working directory available, skipping conflict check.");
1191
+
1192
+ const branch = getCurrentBranch(cwd);
1193
+ if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping conflict check.");
1194
+
1195
+ const baseBranch = (ctx.params?.baseBranch as string) ?? "main";
1196
+ if (branch === baseBranch) {
1197
+ return allow(`On base branch "${baseBranch}", skipping conflict check.`);
1198
+ }
1199
+
1200
+ // -- Layer 1: local git merge-tree --
1201
+ let localSkipped = false;
1202
+ try {
1203
+ execFileSync("git", ["rev-parse", "--verify", `origin/${baseBranch}`], {
1204
+ cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000,
1205
+ });
1206
+
1207
+ const ahead = execFileSync(
1208
+ "git", ["log", `origin/${baseBranch}..HEAD`, "--oneline"],
1209
+ { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
1210
+ ).trim();
1211
+
1212
+ if (!ahead) {
1213
+ // Nothing ahead of base — Layer 1 doesn't apply, fall through to Layer 2.
1214
+ localSkipped = true;
1215
+ } else {
1216
+ execFileSync(
1217
+ "git",
1218
+ ["merge-tree", "--write-tree", "--name-only", `origin/${baseBranch}`, "HEAD"],
1219
+ { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 },
1220
+ );
1221
+ // exit 0 → clean merge, fall through to Layer 2
1222
+ }
1223
+ } catch (err) {
1224
+ const e = err as { status?: number; stdout?: string | Buffer };
1225
+ if (e.status === 1) {
1226
+ // git merge-tree exit 1 = conflicts. stdout: <tree>\n<file>\n<file>\n\n<messages>
1227
+ const out = (typeof e.stdout === "string" ? e.stdout : e.stdout?.toString("utf8") ?? "").trim();
1228
+ const lines = out.split("\n");
1229
+ const files: string[] = [];
1230
+ for (let i = 1; i < lines.length; i++) {
1231
+ const line = lines[i];
1232
+ if (line === "") break;
1233
+ files.push(line);
1234
+ }
1235
+ const fileList = files.length ? files.join(", ") : "one or more files";
1236
+ return deny(
1237
+ `Branch "${branch}" has merge conflicts with ${baseBranch} in: ${fileList}. ` +
1238
+ `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`,
1239
+ );
1240
+ }
1241
+ localSkipped = true;
1242
+ }
1243
+
1244
+ // -- Layer 2: GitHub PR mergeability --
1245
+ try {
1246
+ execSync("gh --version", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
1247
+ } catch {
1248
+ return allow(
1249
+ localSkipped
1250
+ ? "Local conflict check skipped and gh CLI not installed, skipping conflict check."
1251
+ : `Branch "${branch}" merges cleanly with ${baseBranch} locally (gh CLI not installed, PR mergeability not verified).`,
1252
+ );
1253
+ }
1254
+
1255
+ let prJson: string;
1256
+ try {
1257
+ prJson = execSync("gh pr view --json mergeable,number,url,state", {
1258
+ cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000,
1259
+ }).trim();
1260
+ } catch {
1261
+ return allow(
1262
+ localSkipped
1263
+ ? "No pull request found for branch, skipping conflict check."
1264
+ : `Branch "${branch}" merges cleanly with ${baseBranch} locally (no PR to verify against).`,
1265
+ );
1266
+ }
1267
+
1268
+ let pr: { mergeable: string; number: number; url: string; state: string };
1269
+ try {
1270
+ pr = JSON.parse(prJson);
1271
+ } catch {
1272
+ return allow("Could not parse gh pr view output, skipping PR mergeability check.");
1273
+ }
1274
+
1275
+ // GitHub stops computing mergeability for non-OPEN PRs (returns UNKNOWN forever).
1276
+ // Skip the check entirely so a merged or closed PR doesn't trap Stop in a wait loop.
1277
+ if (pr.state !== "OPEN") {
1278
+ return allow(`PR #${pr.number} is ${pr.state.toLowerCase()}; skipping conflict check.`);
1279
+ }
1280
+
1281
+ if (pr.mergeable === "CONFLICTING") {
1282
+ return deny(
1283
+ `PR #${pr.number} has merge conflicts per GitHub (${pr.url}). ` +
1284
+ `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`,
1285
+ );
1286
+ }
1287
+ if (pr.mergeable === "UNKNOWN") {
1288
+ return deny(
1289
+ `GitHub is still computing mergeability for PR #${pr.number} (${pr.url}). ` +
1290
+ `Wait ~10 seconds, then re-check with \`gh pr view --json mergeable\` before attempting to stop again.`,
1291
+ );
1292
+ }
1293
+ return allow(`PR #${pr.number} merges cleanly per GitHub.`);
1294
+ }
1295
+
1188
1296
  function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
1189
1297
  const cwd = ctx.session?.cwd;
1190
1298
  if (!cwd) return allow("No working directory available, skipping CI check.");
@@ -1590,6 +1698,21 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1590
1698
  },
1591
1699
  } satisfies PolicyParamsSchema,
1592
1700
  },
1701
+ {
1702
+ name: "require-no-conflicts-before-stop",
1703
+ description: "Require the current branch to merge cleanly with the base branch before Claude stops",
1704
+ fn: requireNoConflictsBeforeStop,
1705
+ match: { events: ["Stop"] },
1706
+ defaultEnabled: false,
1707
+ category: "Workflow",
1708
+ params: {
1709
+ baseBranch: {
1710
+ type: "string",
1711
+ description: "Base branch to check for conflicts against (default: main)",
1712
+ default: "main",
1713
+ },
1714
+ } satisfies PolicyParamsSchema,
1715
+ },
1593
1716
  {
1594
1717
  name: "require-ci-green-before-stop",
1595
1718
  description: "Require CI checks to pass on the current branch before Claude stops",
@@ -1601,9 +1724,11 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1601
1724
  ];
1602
1725
 
1603
1726
  export function registerBuiltinPolicies(enabledNames: string[]): void {
1604
- const enabledSet = new Set(enabledNames);
1727
+ // Tolerate both flat ("sanitize-jwt") and qualified ("exospherehost/sanitize-jwt")
1728
+ // forms in the user's enabledPolicies config — canonicalize both sides.
1729
+ const enabledSet = new Set(enabledNames.map(normalizePolicyName));
1605
1730
  for (const policy of BUILTIN_POLICIES) {
1606
- if (enabledSet.has(policy.name)) {
1731
+ if (enabledSet.has(normalizePolicyName(policy.name))) {
1607
1732
  registerPolicy(policy.name, policy.description, policy.fn, policy.match);
1608
1733
  }
1609
1734
  }
@@ -5,7 +5,7 @@
5
5
  import type { HookEventType, SessionMetadata } from "./types";
6
6
  import type { PolicyContext, HooksConfig } from "./policy-types";
7
7
  import { BUILTIN_POLICIES } from "./builtin-policies";
8
- import { getPoliciesForEvent } from "./policy-registry";
8
+ import { DEFAULT_POLICY_NAMESPACE, getPoliciesForEvent, normalizePolicyName } from "./policy-registry";
9
9
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
10
10
 
11
11
  function appendHint(baseReason: string, hint: unknown): string {
@@ -26,11 +26,33 @@ export interface EvaluationResult {
26
26
  decision: "allow" | "deny" | "instruct";
27
27
  }
28
28
 
29
- // Build a map from policy name to its params schema (for injecting defaults)
29
+ // Build a map from canonical policy name to its params schema (for injecting defaults).
30
+ // Keyed by canonical name because registered policies always carry the canonical form.
30
31
  const POLICY_PARAMS_MAP = new Map(
31
- BUILTIN_POLICIES.filter((p) => p.params).map((p) => [p.name, p.params!]),
32
+ BUILTIN_POLICIES.filter((p) => p.params).map((p) => [normalizePolicyName(p.name), p.params!]),
32
33
  );
33
34
 
35
+ /**
36
+ * Look up policy params for a canonical policy name in the user config,
37
+ * tolerating either flat ("block-force-push") or qualified
38
+ * ("exospherehost/block-force-push") config keys for built-in policies.
39
+ *
40
+ * The flat-key fallback is intentionally limited to the default namespace
41
+ * so namespace isolation is preserved: `policyParams.foo` only matches
42
+ * `exospherehost/foo`, never `myorg/foo` or `custom/foo`.
43
+ */
44
+ function getConfigParamsFor(
45
+ config: HooksConfig | undefined,
46
+ canonicalName: string,
47
+ ): Record<string, unknown> | undefined {
48
+ if (!config?.policyParams) return undefined;
49
+ const canonicalParams = config.policyParams[canonicalName];
50
+ if (canonicalParams) return canonicalParams;
51
+ const defaultPrefix = `${DEFAULT_POLICY_NAMESPACE}/`;
52
+ if (!canonicalName.startsWith(defaultPrefix)) return undefined;
53
+ return config.policyParams[canonicalName.slice(defaultPrefix.length)];
54
+ }
55
+
34
56
  export async function evaluatePolicies(
35
57
  eventType: HookEventType,
36
58
  payload: Record<string, unknown>,
@@ -63,11 +85,13 @@ export async function evaluatePolicies(
63
85
  const allowEntries: Array<{ policyName: string; reason: string }> = [];
64
86
 
65
87
  for (const policy of policies) {
66
- // Inject params: merge policyParams[policy.name] over schema defaults
88
+ // Inject params: merge policyParams[policy.name] over schema defaults.
89
+ // policy.name is canonical (e.g. "exospherehost/block-force-push"); user
90
+ // config keys may be flat or canonical — getConfigParamsFor accepts both.
67
91
  const schema = POLICY_PARAMS_MAP.get(policy.name);
68
92
  let ctx: PolicyContext;
69
93
  if (schema) {
70
- const userParams = config?.policyParams?.[policy.name] ?? {};
94
+ const userParams = getConfigParamsFor(config, policy.name) ?? {};
71
95
  const resolvedParams: Record<string, unknown> = {};
72
96
  for (const [key, spec] of Object.entries(schema)) {
73
97
  resolvedParams[key] = key in userParams ? userParams[key] : spec.default;
@@ -89,7 +113,7 @@ export async function evaluatePolicies(
89
113
  if (result.decision === "deny") {
90
114
  const reason = appendHint(
91
115
  result.reason ?? `Blocked by policy: ${policy.name}`,
92
- config?.policyParams?.[policy.name]?.hint,
116
+ getConfigParamsFor(config, policy.name)?.hint,
93
117
  );
94
118
  hookLogInfo(`deny by "${policy.name}": ${reason}`);
95
119
 
@@ -156,7 +180,7 @@ export async function evaluatePolicies(
156
180
  if (result.decision === "instruct") {
157
181
  const reason = appendHint(
158
182
  result.reason ?? `Instruction from policy: ${policy.name}`,
159
- config?.policyParams?.[policy.name]?.hint,
183
+ getConfigParamsFor(config, policy.name)?.hint,
160
184
  );
161
185
  instructEntries.push({ policyName: policy.name, reason });
162
186
  hookLogInfo(`instruct by "${policy.name}": ${reason}`);
@@ -11,6 +11,22 @@ import type { PolicyFunction, PolicyMatcher, RegisteredPolicy } from "./policy-t
11
11
  const REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__";
12
12
  const INDEX_CACHE_KEY = "__FAILPROOFAI_POLICY_INDEX_CACHE__";
13
13
 
14
+ /**
15
+ * The default namespace applied to any policy name registered without a
16
+ * `<namespace>/` prefix. Builtins live under this namespace; custom hooks
17
+ * loaded by the handler get their own prefixes (e.g. `custom/foo`).
18
+ */
19
+ export const DEFAULT_POLICY_NAMESPACE = "exospherehost";
20
+
21
+ /**
22
+ * Canonicalize a policy name. If the name already contains a `/`, it is
23
+ * treated as already-namespaced and returned unchanged. Otherwise the
24
+ * default namespace is prepended.
25
+ */
26
+ export function normalizePolicyName(name: string): string {
27
+ return name.includes("/") ? name : `${DEFAULT_POLICY_NAMESPACE}/${name}`;
28
+ }
29
+
14
30
  interface GlobalWithRegistry {
15
31
  [REGISTRY_KEY]?: RegisteredPolicy[];
16
32
  }
@@ -42,9 +58,10 @@ export function registerPolicy(
42
58
  match: PolicyMatcher,
43
59
  priority: number = 0,
44
60
  ): void {
61
+ const canonical = normalizePolicyName(name);
45
62
  const registry = getRegistry();
46
- const idx = registry.findIndex((p) => p.name === name);
47
- const entry: RegisteredPolicy = { name, description, fn, match, priority };
63
+ const idx = registry.findIndex((p) => p.name === canonical);
64
+ const entry: RegisteredPolicy = { name: canonical, description, fn, match, priority };
48
65
  if (idx >= 0) {
49
66
  registry[idx] = entry;
50
67
  } else {