failproofai 0.0.2-beta.2 → 0.0.2-beta.3

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 (124) 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/server/app/_global-error/page/server-reference-manifest.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  9. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  12. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  19. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  20. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  21. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  22. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  24. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/index.html +1 -1
  26. package/.next/standalone/.next/server/app/index.rsc +16 -16
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  33. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  36. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  39. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  42. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  43. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  46. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/chunks/[root-of-the-server]__02nt~6d._.js +1 -1
  49. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  50. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0103jwf._.js → [root-of-the-server]__07k6eu-._.js} +2 -2
  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]__0ovwjau._.js → [root-of-the-server]__0kfv9fw._.js} +2 -2
  56. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okos0k._.js +2 -2
  57. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +5 -5
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +1 -1
  63. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  64. package/.next/standalone/.next/server/pages/404.html +2 -2
  65. package/.next/standalone/.next/server/pages/500.html +1 -1
  66. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  68. package/.next/standalone/.next/static/chunks/{0uftmw5od9kdz.js → 0.jo.465b6_k..js} +1 -1
  69. package/.next/standalone/.next/static/chunks/{0wtcha31~i7rm.js → 01haq0a3zrx0v.js} +1 -1
  70. package/.next/standalone/.next/static/chunks/08f78tecvx61l.css +1 -0
  71. package/.next/standalone/.next/static/chunks/{0tl2f-3yc.rqc.js → 0a6xi1a8f_qlp.js} +1 -1
  72. package/.next/standalone/.next/static/chunks/{0tbr0o7vwc~-s.js → 0mq7ze1vkeo1p.js} +1 -1
  73. package/.next/standalone/.next/static/chunks/{0sm1iqi3m~xiz.js → 0p_fpyfmmohnx.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/{0pdd7~yp8ytu6.js → 0qwyj3m400l_g.js} +1 -1
  75. package/.next/standalone/.next/static/chunks/{001k0zayn2o.s.js → 0t94r_mk0s7e4.js} +1 -1
  76. package/.next/standalone/.next/static/chunks/{0jrzwsyo7wo26.js → 139~00zc9.u7s.js} +1 -1
  77. package/.next/standalone/Dockerfile.docs +12 -0
  78. package/.next/standalone/README.md +59 -46
  79. package/.next/standalone/dist/cli.mjs +215 -20
  80. package/.next/standalone/dist/index.js +2 -2
  81. package/.next/standalone/docs/{architecture.md → architecture.mdx} +40 -23
  82. package/.next/standalone/docs/{built-in-policies.md → built-in-policies.mdx} +134 -12
  83. package/.next/standalone/docs/cli/dashboard.mdx +28 -0
  84. package/.next/standalone/docs/cli/environment-variables.mdx +34 -0
  85. package/.next/standalone/docs/cli/hook.mdx +30 -0
  86. package/.next/standalone/docs/cli/install-policies.mdx +48 -0
  87. package/.next/standalone/docs/cli/list-policies.mdx +31 -0
  88. package/.next/standalone/docs/cli/remove-policies.mdx +44 -0
  89. package/.next/standalone/docs/cli/version.mdx +12 -0
  90. package/.next/standalone/docs/{configuration.md → configuration.mdx} +16 -16
  91. package/.next/standalone/docs/{custom-hooks.md → custom-policies.mdx} +80 -42
  92. package/.next/standalone/docs/{dashboard.md → dashboard.mdx} +26 -29
  93. package/.next/standalone/docs/docs.json +31 -4
  94. package/.next/standalone/docs/examples.mdx +253 -0
  95. package/.next/standalone/docs/for-agents.mdx +38 -0
  96. package/.next/standalone/docs/getting-started.mdx +134 -0
  97. package/.next/standalone/docs/introduction.mdx +57 -0
  98. package/.next/standalone/docs/logo/dark.svg +21 -0
  99. package/.next/standalone/docs/logo/light.svg +21 -0
  100. package/.next/standalone/docs/{package-aliases.md → package-aliases.mdx} +5 -5
  101. package/.next/standalone/docs/{testing.md → testing.mdx} +11 -11
  102. package/.next/standalone/package.json +6 -9
  103. package/.next/standalone/scripts/publish-aliases.mjs +4 -2
  104. package/.next/standalone/skills-lock.json +10 -0
  105. package/.next/standalone/src/hooks/builtin-policies.ts +259 -20
  106. package/.next/standalone/src/hooks/policy-evaluator.ts +19 -1
  107. package/.next/standalone/src/hooks/policy-helpers.ts +2 -2
  108. package/.next/standalone/vitest.config.e2e.mts +3 -0
  109. package/.next/standalone/vitest.config.mts +3 -0
  110. package/README.md +59 -46
  111. package/dist/cli.mjs +215 -20
  112. package/dist/index.js +2 -2
  113. package/package.json +6 -9
  114. package/scripts/publish-aliases.mjs +4 -2
  115. package/src/hooks/builtin-policies.ts +259 -20
  116. package/src/hooks/policy-evaluator.ts +19 -1
  117. package/src/hooks/policy-helpers.ts +2 -2
  118. package/.next/standalone/.next/static/chunks/15jpradyu_531.css +0 -1
  119. package/.next/standalone/docs/cli-reference.md +0 -175
  120. package/.next/standalone/docs/getting-started.md +0 -128
  121. package/.next/standalone/docs/introduction.md +0 -47
  122. /package/.next/standalone/.next/static/{JksWDLwDoPy6bcczVWlff → 7fR022u1Sj-s5MfKO1q9Y}/_buildManifest.js +0 -0
  123. /package/.next/standalone/.next/static/{JksWDLwDoPy6bcczVWlff → 7fR022u1Sj-s5MfKO1q9Y}/_clientMiddlewareManifest.js +0 -0
  124. /package/.next/standalone/.next/static/{JksWDLwDoPy6bcczVWlff → 7fR022u1Sj-s5MfKO1q9Y}/_ssgManifest.js +0 -0
package/dist/cli.mjs CHANGED
@@ -179,8 +179,8 @@ var init_hooks_config = __esm(() => {
179
179
  });
180
180
 
181
181
  // src/hooks/policy-helpers.ts
182
- function allow() {
183
- return { decision: "allow" };
182
+ function allow(reason) {
183
+ return reason ? { decision: "allow", reason } : { decision: "allow" };
184
184
  }
185
185
  function deny(reason) {
186
186
  return { decision: "deny", reason };
@@ -248,7 +248,7 @@ var REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__", INDEX_CACHE_KEY = "__FAILP
248
248
  // src/hooks/builtin-policies.ts
249
249
  import { resolve as resolve2, join as join2 } from "node:path";
250
250
  import { readFile, writeFile } from "node:fs/promises";
251
- import { execSync } from "node:child_process";
251
+ import { execSync, execFileSync } from "node:child_process";
252
252
  import { homedir as homedir3 } from "node:os";
253
253
  function isClaudeInternalPath(resolved2) {
254
254
  const claudeDir = join2(homedir3(), ".claude");
@@ -266,6 +266,22 @@ function getFilePath(ctx) {
266
266
  function parseArgvTokens(cmd) {
267
267
  return cmd.trim().split(/\s+/).map((t) => t.replace(/^['"]|['"]$/g, ""));
268
268
  }
269
+ function getCurrentBranch(cwd) {
270
+ try {
271
+ let branch = gitBranchCache.get(cwd);
272
+ if (branch === undefined) {
273
+ branch = execSync("git rev-parse --abbrev-ref HEAD", {
274
+ cwd,
275
+ encoding: "utf8",
276
+ timeout: 3000
277
+ }).trim();
278
+ gitBranchCache.set(cwd, branch);
279
+ }
280
+ return branch || null;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
269
285
  function matchesAllowedPattern(cmd, pattern) {
270
286
  const cmdTokens = parseArgvTokens(cmd);
271
287
  const patTokens = parseArgvTokens(pattern);
@@ -653,22 +669,12 @@ function blockWorkOnMain(ctx) {
653
669
  const cwd = ctx.session?.cwd;
654
670
  if (!cwd)
655
671
  return allow();
656
- try {
657
- let branch = gitBranchCache.get(cwd);
658
- if (branch === undefined) {
659
- branch = execSync("git rev-parse --abbrev-ref HEAD", {
660
- cwd,
661
- encoding: "utf8",
662
- timeout: 3000
663
- }).trim();
664
- gitBranchCache.set(cwd, branch);
665
- }
666
- const protectedBranches = ctx.params?.protectedBranches ?? ["main", "master"];
667
- if (protectedBranches.includes(branch)) {
668
- return deny(`Git ${cmd.match(/git\s+(\S+)/)?.[1] ?? "operation"} on ${branch} is blocked. Create a feature branch first.`);
669
- }
670
- } catch {
672
+ const branch = getCurrentBranch(cwd);
673
+ if (!branch)
671
674
  return allow();
675
+ const protectedBranches = ctx.params?.protectedBranches ?? ["main", "master"];
676
+ if (protectedBranches.includes(branch)) {
677
+ return deny(`Git ${cmd.match(/git\s+(\S+)/)?.[1] ?? "operation"} on ${branch} is blocked. Create a feature branch first.`);
672
678
  }
673
679
  return allow();
674
680
  }
@@ -767,6 +773,137 @@ function warnBackgroundProcess(ctx) {
767
773
  }
768
774
  return allow();
769
775
  }
776
+ function requireCommitBeforeStop(ctx) {
777
+ const cwd = ctx.session?.cwd;
778
+ if (!cwd)
779
+ return allow("No working directory available, skipping commit check.");
780
+ try {
781
+ const status = execSync("git status --porcelain", {
782
+ cwd,
783
+ encoding: "utf8",
784
+ timeout: 5000
785
+ }).trim();
786
+ if (status.length > 0) {
787
+ return deny("You have uncommitted changes in the working directory. Commit all changes before stopping.");
788
+ }
789
+ return allow("All changes are committed.");
790
+ } catch {
791
+ return allow("Not a git repository, skipping commit check.");
792
+ }
793
+ }
794
+ function requirePushBeforeStop(ctx) {
795
+ const cwd = ctx.session?.cwd;
796
+ if (!cwd)
797
+ return allow("No working directory available, skipping push check.");
798
+ try {
799
+ const remotes = execSync("git remote", {
800
+ cwd,
801
+ encoding: "utf8",
802
+ timeout: 3000
803
+ }).trim();
804
+ if (!remotes)
805
+ return allow("No git remote configured, skipping push check.");
806
+ const remote = ctx.params?.remote ?? "origin";
807
+ const branch = getCurrentBranch(cwd);
808
+ if (!branch || branch === "HEAD")
809
+ return allow("Detached HEAD, skipping push check.");
810
+ let hasTracking = false;
811
+ try {
812
+ execFileSync("git", ["rev-parse", "--verify", `${remote}/${branch}`], {
813
+ cwd,
814
+ encoding: "utf8",
815
+ timeout: 3000
816
+ });
817
+ hasTracking = true;
818
+ } catch {}
819
+ if (!hasTracking) {
820
+ return deny(`Branch "${branch}" has not been pushed to remote "${remote}". ` + `Push your branch with: git push -u ${remote} ${branch}`);
821
+ }
822
+ const unpushed = execFileSync("git", ["log", `${remote}/${branch}..HEAD`, "--oneline"], {
823
+ cwd,
824
+ encoding: "utf8",
825
+ timeout: 5000
826
+ }).trim();
827
+ if (unpushed.length > 0) {
828
+ const commitCount = unpushed.split(`
829
+ `).length;
830
+ return deny(`You have ${commitCount} unpushed commit${commitCount > 1 ? "s" : ""} on branch "${branch}". ` + `Push your changes with: git push`);
831
+ }
832
+ return allow(`All commits pushed to "${remote}".`);
833
+ } catch {
834
+ return allow("Could not check push status, skipping.");
835
+ }
836
+ }
837
+ function requirePrBeforeStop(ctx) {
838
+ const cwd = ctx.session?.cwd;
839
+ if (!cwd)
840
+ return allow("No working directory available, skipping PR check.");
841
+ try {
842
+ try {
843
+ execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
844
+ } catch {
845
+ return allow("GitHub CLI (gh) not installed, skipping PR check.");
846
+ }
847
+ const branch = getCurrentBranch(cwd);
848
+ if (!branch || branch === "HEAD")
849
+ return allow("Detached HEAD, skipping PR check.");
850
+ let prJson;
851
+ try {
852
+ prJson = execSync("gh pr view --json number,url,state", {
853
+ cwd,
854
+ encoding: "utf8",
855
+ timeout: 15000
856
+ }).trim();
857
+ } catch {
858
+ return deny(`No pull request found for branch "${branch}". ` + `Create one with: gh pr create`);
859
+ }
860
+ const pr = JSON.parse(prJson);
861
+ if (pr.state === "OPEN") {
862
+ return allow(`PR #${pr.number} exists: ${pr.url}`);
863
+ }
864
+ return deny(`Pull request for branch "${branch}" is ${pr.state.toLowerCase()}. Create a new PR with: gh pr create`);
865
+ } catch {
866
+ return allow("Could not check PR status, skipping.");
867
+ }
868
+ }
869
+ function requireCiGreenBeforeStop(ctx) {
870
+ const cwd = ctx.session?.cwd;
871
+ if (!cwd)
872
+ return allow("No working directory available, skipping CI check.");
873
+ try {
874
+ try {
875
+ execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
876
+ } catch {
877
+ return allow("GitHub CLI (gh) not installed, skipping CI check.");
878
+ }
879
+ const branch = getCurrentBranch(cwd);
880
+ if (!branch || branch === "HEAD")
881
+ return allow("Detached HEAD, skipping CI check.");
882
+ const runsJson = execFileSync("gh", ["run", "list", "--branch", branch, "--limit", "5", "--json", "status,conclusion,name"], {
883
+ cwd,
884
+ encoding: "utf8",
885
+ timeout: 15000
886
+ }).trim();
887
+ if (!runsJson || runsJson === "[]")
888
+ return allow(`No CI runs found for branch "${branch}".`);
889
+ const runs = JSON.parse(runsJson);
890
+ if (runs.length === 0)
891
+ return allow(`No CI runs found for branch "${branch}".`);
892
+ const failing = runs.filter((r) => r.status === "completed" && r.conclusion !== "success" && r.conclusion !== "skipped");
893
+ if (failing.length > 0) {
894
+ const names = failing.map((r) => `"${r.name}"`).join(", ");
895
+ return deny(`CI checks are failing on branch "${branch}": ${names}. Fix the failing checks before stopping.`);
896
+ }
897
+ const pending = runs.filter((r) => r.status === "in_progress" || r.status === "queued" || r.status === "waiting");
898
+ if (pending.length > 0) {
899
+ const names = pending.map((r) => `"${r.name}"`).join(", ");
900
+ return deny(`CI checks are still running on branch "${branch}": ${names}. Wait for all checks to complete and verify they pass.`);
901
+ }
902
+ return allow(`All CI checks passed on branch "${branch}".`);
903
+ } catch {
904
+ return allow("Could not check CI status, skipping.");
905
+ }
906
+ }
770
907
  function registerBuiltinPolicies(enabledNames) {
771
908
  const enabledSet = new Set(enabledNames);
772
909
  for (const policy of BUILTIN_POLICIES) {
@@ -1102,6 +1239,49 @@ var init_builtin_policies = __esm(() => {
1102
1239
  match: { events: ["PreToolUse"] },
1103
1240
  defaultEnabled: false,
1104
1241
  category: "AI Behavior"
1242
+ },
1243
+ {
1244
+ name: "require-commit-before-stop",
1245
+ description: "Require all changes to be committed before Claude stops",
1246
+ fn: requireCommitBeforeStop,
1247
+ match: { events: ["Stop"] },
1248
+ defaultEnabled: false,
1249
+ category: "Workflow",
1250
+ beta: true
1251
+ },
1252
+ {
1253
+ name: "require-push-before-stop",
1254
+ description: "Require all commits to be pushed to remote before Claude stops",
1255
+ fn: requirePushBeforeStop,
1256
+ match: { events: ["Stop"] },
1257
+ defaultEnabled: false,
1258
+ category: "Workflow",
1259
+ beta: true,
1260
+ params: {
1261
+ remote: {
1262
+ type: "string",
1263
+ description: "Remote name to push to (default: origin)",
1264
+ default: "origin"
1265
+ }
1266
+ }
1267
+ },
1268
+ {
1269
+ name: "require-pr-before-stop",
1270
+ description: "Require a pull request to exist for the current branch before Claude stops",
1271
+ fn: requirePrBeforeStop,
1272
+ match: { events: ["Stop"] },
1273
+ defaultEnabled: false,
1274
+ category: "Workflow",
1275
+ beta: true
1276
+ },
1277
+ {
1278
+ name: "require-ci-green-before-stop",
1279
+ description: "Require CI checks to pass on the current branch before Claude stops",
1280
+ fn: requireCiGreenBeforeStop,
1281
+ match: { events: ["Stop"] },
1282
+ defaultEnabled: false,
1283
+ category: "Workflow",
1284
+ beta: true
1105
1285
  }
1106
1286
  ];
1107
1287
  });
@@ -1124,6 +1304,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1124
1304
  };
1125
1305
  let instructPolicyName = null;
1126
1306
  let instructReason = null;
1307
+ const allowMessages = [];
1127
1308
  for (const policy of policies) {
1128
1309
  const schema = POLICY_PARAMS_MAP.get(policy.name);
1129
1310
  let ctx;
@@ -1195,6 +1376,9 @@ async function evaluatePolicies(eventType, payload, session, config) {
1195
1376
  instructReason = result.reason ?? `Instruction from policy: ${policy.name}`;
1196
1377
  hookLogInfo(`instruct by "${policy.name}": ${instructReason}`);
1197
1378
  }
1379
+ if (result.decision === "allow" && result.reason) {
1380
+ allowMessages.push(result.reason);
1381
+ }
1198
1382
  }
1199
1383
  if (instructPolicyName && instructReason) {
1200
1384
  if (eventType === "Stop") {
@@ -1222,6 +1406,17 @@ async function evaluatePolicies(eventType, payload, session, config) {
1222
1406
  decision: "instruct"
1223
1407
  };
1224
1408
  }
1409
+ if (allowMessages.length > 0) {
1410
+ const combined = allowMessages.join(`
1411
+ `);
1412
+ const response = {
1413
+ hookSpecificOutput: {
1414
+ hookEventName: eventType,
1415
+ additionalContext: combined
1416
+ }
1417
+ };
1418
+ return { exitCode: 0, stdout: JSON.stringify(response), stderr: "", policyName: null, reason: combined, decision: "allow" };
1419
+ }
1225
1420
  return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
1226
1421
  }
1227
1422
  var POLICY_PARAMS_MAP;
@@ -1536,7 +1731,7 @@ var init_hook_activity_store = __esm(() => {
1536
1731
  });
1537
1732
 
1538
1733
  // package.json
1539
- var version2 = "0.0.2-beta.2";
1734
+ var version2 = "0.0.2-beta.3";
1540
1735
  var init_package = () => {};
1541
1736
 
1542
1737
  // src/posthog-key.ts
@@ -2762,7 +2957,7 @@ import { realpathSync as realpathSync2 } from "fs";
2762
2957
  import { dirname as dirname5, resolve as resolve8 } from "path";
2763
2958
  import { fileURLToPath as fileURLToPath2 } from "url";
2764
2959
  // package.json
2765
- var version = "0.0.2-beta.2";
2960
+ var version = "0.0.2-beta.3";
2766
2961
 
2767
2962
  // bin/failproofai.mjs
2768
2963
  if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
package/dist/index.js CHANGED
@@ -69,8 +69,8 @@ function clearCustomHooks() {
69
69
  g[REGISTRY_KEY] = [];
70
70
  }
71
71
  // src/hooks/policy-helpers.ts
72
- function allow() {
73
- return { decision: "allow" };
72
+ function allow(reason) {
73
+ return reason ? { decision: "allow", reason } : { decision: "allow" };
74
74
  }
75
75
  function deny(reason) {
76
76
  return { decision: "deny", reason };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.2-beta.2",
4
- "description": "Open-source hooks, policies, and project visualization for Claude Code & Agents SDK",
3
+ "version": "0.0.2-beta.3",
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
  },
@@ -40,17 +40,14 @@
40
40
  "claude-agents-sdk",
41
41
  "anthropic",
42
42
  "ai-agent",
43
- "llm-observability",
44
- "agent-observability",
45
- "log-viewer",
46
- "session-replay",
43
+ "agent-reliability",
44
+ "agent-monitoring",
45
+ "autonomous-agent",
46
+ "failure-prevention",
47
47
  "developer-tools",
48
48
  "devtools",
49
49
  "cli",
50
50
  "local-first",
51
- "monitoring",
52
- "debugging",
53
- "tracing",
54
51
  "hooks",
55
52
  "policies"
56
53
  ],
@@ -8,6 +8,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const rootPkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
9
9
  const VERSION = rootPkg.version;
10
10
  const DRY_RUN = process.argv.includes('--dry-run');
11
+ const distTagIdx = process.argv.indexOf('--dist-tag');
12
+ const DIST_TAG = distTagIdx !== -1 ? process.argv[distTagIdx + 1] : (VERSION.includes('-') ? 'beta' : 'latest');
11
13
 
12
14
  const ALIASES = [
13
15
  // Formatting variants — no "ai", or hyphen/underscore separators
@@ -54,7 +56,7 @@ for (const name of ALIASES) {
54
56
  cpSync(join(__dirname, 'alias-proxy.js'), join(binDir, 'proxy.js'));
55
57
 
56
58
  if (DRY_RUN) {
57
- console.log(`[dry-run] Would publish ${name}@${VERSION}`);
59
+ console.log(`[dry-run] Would publish ${name}@${VERSION} (tag: ${DIST_TAG})`);
58
60
  console.log(JSON.stringify(pkg, null, 2));
59
61
  console.log('---');
60
62
  rmSync(tmpDir, { recursive: true, force: true });
@@ -63,7 +65,7 @@ for (const name of ALIASES) {
63
65
 
64
66
  console.log(`Publishing ${name}@${VERSION}...`);
65
67
  try {
66
- execSync('npm publish', { cwd: tmpDir, stdio: 'pipe' });
68
+ execSync(`npm publish --tag ${DIST_TAG}`, { cwd: tmpDir, stdio: 'pipe' });
67
69
  console.log(`Done: ${name}`);
68
70
  } catch (err) {
69
71
  const output = (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? '');
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { resolve, join } from "node:path";
5
5
  import { readFile, writeFile, stat, open } from "node:fs/promises";
6
- import { execSync } from "node:child_process";
6
+ 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";
@@ -145,12 +145,29 @@ const TMUX_DETACH_RE = /\btmux\s+(?:new-session|new)\b[^|&;]*-d\b/;
145
145
  const DISOWN_RE = /\bdisown\b/;
146
146
  const BACKGROUND_AMPERSAND_RE = /(?<![&|])\s?&\s*(?:$|#|;)/;
147
147
 
148
- // blockWorkOnMain: caches the current branch per cwd to avoid repeated execSync calls.
148
+ // Caches the current branch per cwd to avoid repeated execSync calls.
149
149
  // Trade-off: if the user switches branches externally mid-session, the cache serves
150
150
  // the stale value until the process restarts. This is acceptable since branch switches
151
151
  // during an active Claude session are rare.
152
152
  const gitBranchCache = new Map<string, string>();
153
153
 
154
+ function getCurrentBranch(cwd: string): string | null {
155
+ try {
156
+ let branch = gitBranchCache.get(cwd);
157
+ if (branch === undefined) {
158
+ branch = execSync("git rev-parse --abbrev-ref HEAD", {
159
+ cwd,
160
+ encoding: "utf8",
161
+ timeout: 3000,
162
+ }).trim();
163
+ gitBranchCache.set(cwd, branch);
164
+ }
165
+ return branch || null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
154
171
  /**
155
172
  * Check if a command matches an allow pattern using token-by-token comparison.
156
173
  * The "*" token is a wildcard. Extra command tokens beyond the pattern are allowed,
@@ -627,24 +644,14 @@ function blockWorkOnMain(ctx: PolicyContext): PolicyResult {
627
644
  const cwd = ctx.session?.cwd;
628
645
  if (!cwd) return allow();
629
646
 
630
- try {
631
- let branch = gitBranchCache.get(cwd);
632
- if (branch === undefined) {
633
- branch = execSync("git rev-parse --abbrev-ref HEAD", {
634
- cwd,
635
- encoding: "utf8",
636
- timeout: 3000,
637
- }).trim();
638
- gitBranchCache.set(cwd, branch);
639
- }
640
- const protectedBranches = ((ctx.params?.protectedBranches ?? ["main", "master"]) as string[]);
641
- if (protectedBranches.includes(branch)) {
642
- return deny(
643
- `Git ${cmd.match(/git\s+(\S+)/)?.[1] ?? "operation"} on ${branch} is blocked. Create a feature branch first.`,
644
- );
645
- }
646
- } catch {
647
- return allow();
647
+ const branch = getCurrentBranch(cwd);
648
+ if (!branch) return allow();
649
+
650
+ const protectedBranches = ((ctx.params?.protectedBranches ?? ["main", "master"]) as string[]);
651
+ if (protectedBranches.includes(branch)) {
652
+ return deny(
653
+ `Git ${cmd.match(/git\s+(\S+)/)?.[1] ?? "operation"} on ${branch} is blocked. Create a feature branch first.`,
654
+ );
648
655
  }
649
656
  return allow();
650
657
  }
@@ -786,6 +793,195 @@ function warnBackgroundProcess(ctx: PolicyContext): PolicyResult {
786
793
  return allow();
787
794
  }
788
795
 
796
+ // -- Workflow (Stop event) policies --
797
+
798
+ function requireCommitBeforeStop(ctx: PolicyContext): PolicyResult {
799
+ const cwd = ctx.session?.cwd;
800
+ if (!cwd) return allow("No working directory available, skipping commit check.");
801
+
802
+ try {
803
+ const status = execSync("git status --porcelain", {
804
+ cwd,
805
+ encoding: "utf8",
806
+ timeout: 5000,
807
+ }).trim();
808
+
809
+ if (status.length > 0) {
810
+ return deny(
811
+ "You have uncommitted changes in the working directory. Commit all changes before stopping.",
812
+ );
813
+ }
814
+ return allow("All changes are committed.");
815
+ } catch {
816
+ return allow("Not a git repository, skipping commit check.");
817
+ }
818
+ }
819
+
820
+ function requirePushBeforeStop(ctx: PolicyContext): PolicyResult {
821
+ const cwd = ctx.session?.cwd;
822
+ if (!cwd) return allow("No working directory available, skipping push check.");
823
+
824
+ try {
825
+ const remotes = execSync("git remote", {
826
+ cwd,
827
+ encoding: "utf8",
828
+ timeout: 3000,
829
+ }).trim();
830
+
831
+ if (!remotes) return allow("No git remote configured, skipping push check.");
832
+
833
+ const remote = (ctx.params?.remote as string) ?? "origin";
834
+
835
+ const branch = getCurrentBranch(cwd);
836
+ if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping push check.");
837
+
838
+ // Check if remote tracking branch exists
839
+ let hasTracking = false;
840
+ try {
841
+ execFileSync("git", ["rev-parse", "--verify", `${remote}/${branch}`], {
842
+ cwd,
843
+ encoding: "utf8",
844
+ timeout: 3000,
845
+ });
846
+ hasTracking = true;
847
+ } catch {
848
+ // Remote tracking branch does not exist
849
+ }
850
+
851
+ if (!hasTracking) {
852
+ return deny(
853
+ `Branch "${branch}" has not been pushed to remote "${remote}". ` +
854
+ `Push your branch with: git push -u ${remote} ${branch}`,
855
+ );
856
+ }
857
+
858
+ // Check for unpushed commits
859
+ const unpushed = execFileSync("git", ["log", `${remote}/${branch}..HEAD`, "--oneline"], {
860
+ cwd,
861
+ encoding: "utf8",
862
+ timeout: 5000,
863
+ }).trim();
864
+
865
+ if (unpushed.length > 0) {
866
+ const commitCount = unpushed.split("\n").length;
867
+ return deny(
868
+ `You have ${commitCount} unpushed commit${commitCount > 1 ? "s" : ""} on branch "${branch}". ` +
869
+ `Push your changes with: git push`,
870
+ );
871
+ }
872
+
873
+ return allow(`All commits pushed to "${remote}".`);
874
+ } catch {
875
+ return allow("Could not check push status, skipping.");
876
+ }
877
+ }
878
+
879
+ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
880
+ const cwd = ctx.session?.cwd;
881
+ if (!cwd) return allow("No working directory available, skipping PR check.");
882
+
883
+ try {
884
+ // Check if gh CLI is available
885
+ try {
886
+ execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
887
+ } catch {
888
+ return allow("GitHub CLI (gh) not installed, skipping PR check.");
889
+ }
890
+
891
+ const branch = getCurrentBranch(cwd);
892
+ if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping PR check.");
893
+
894
+ // Check if a PR exists for this branch
895
+ let prJson: string;
896
+ try {
897
+ prJson = execSync("gh pr view --json number,url,state", {
898
+ cwd,
899
+ encoding: "utf8",
900
+ timeout: 15000,
901
+ }).trim();
902
+ } catch {
903
+ // gh pr view exits non-zero when no PR exists
904
+ return deny(
905
+ `No pull request found for branch "${branch}". ` +
906
+ `Create one with: gh pr create`,
907
+ );
908
+ }
909
+
910
+ const pr = JSON.parse(prJson) as { number: number; url: string; state: string };
911
+
912
+ if (pr.state === "OPEN") {
913
+ return allow(`PR #${pr.number} exists: ${pr.url}`);
914
+ }
915
+
916
+ return deny(
917
+ `Pull request for branch "${branch}" is ${pr.state.toLowerCase()}. Create a new PR with: gh pr create`,
918
+ );
919
+ } catch {
920
+ return allow("Could not check PR status, skipping.");
921
+ }
922
+ }
923
+
924
+ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
925
+ const cwd = ctx.session?.cwd;
926
+ if (!cwd) return allow("No working directory available, skipping CI check.");
927
+
928
+ try {
929
+ // Check if gh CLI is available
930
+ try {
931
+ execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
932
+ } catch {
933
+ return allow("GitHub CLI (gh) not installed, skipping CI check.");
934
+ }
935
+
936
+ const branch = getCurrentBranch(cwd);
937
+ if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping CI check.");
938
+
939
+ const runsJson = execFileSync(
940
+ "gh",
941
+ ["run", "list", "--branch", branch, "--limit", "5", "--json", "status,conclusion,name"],
942
+ {
943
+ cwd,
944
+ encoding: "utf8",
945
+ timeout: 15000,
946
+ },
947
+ ).trim();
948
+
949
+ if (!runsJson || runsJson === "[]") return allow(`No CI runs found for branch "${branch}".`);
950
+
951
+ const runs = JSON.parse(runsJson) as Array<{
952
+ status: string;
953
+ conclusion: string;
954
+ name: string;
955
+ }>;
956
+
957
+ if (runs.length === 0) return allow(`No CI runs found for branch "${branch}".`);
958
+
959
+ const failing = runs.filter(
960
+ (r) => r.status === "completed" && r.conclusion !== "success" && r.conclusion !== "skipped",
961
+ );
962
+ if (failing.length > 0) {
963
+ const names = failing.map((r) => `"${r.name}"`).join(", ");
964
+ return deny(
965
+ `CI checks are failing on branch "${branch}": ${names}. Fix the failing checks before stopping.`,
966
+ );
967
+ }
968
+
969
+ const pending = runs.filter(
970
+ (r) => r.status === "in_progress" || r.status === "queued" || r.status === "waiting",
971
+ );
972
+ if (pending.length > 0) {
973
+ const names = pending.map((r) => `"${r.name}"`).join(", ");
974
+ return deny(
975
+ `CI checks are still running on branch "${branch}": ${names}. Wait for all checks to complete and verify they pass.`,
976
+ );
977
+ }
978
+
979
+ return allow(`All CI checks passed on branch "${branch}".`);
980
+ } catch {
981
+ return allow("Could not check CI status, skipping.");
982
+ }
983
+ }
984
+
789
985
  // -- Registry --
790
986
 
791
987
  export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
@@ -1053,6 +1249,49 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1053
1249
  defaultEnabled: false,
1054
1250
  category: "AI Behavior",
1055
1251
  },
1252
+ {
1253
+ name: "require-commit-before-stop",
1254
+ description: "Require all changes to be committed before Claude stops",
1255
+ fn: requireCommitBeforeStop,
1256
+ match: { events: ["Stop"] },
1257
+ defaultEnabled: false,
1258
+ category: "Workflow",
1259
+ beta: true,
1260
+ },
1261
+ {
1262
+ name: "require-push-before-stop",
1263
+ description: "Require all commits to be pushed to remote before Claude stops",
1264
+ fn: requirePushBeforeStop,
1265
+ match: { events: ["Stop"] },
1266
+ defaultEnabled: false,
1267
+ category: "Workflow",
1268
+ beta: true,
1269
+ params: {
1270
+ remote: {
1271
+ type: "string",
1272
+ description: "Remote name to push to (default: origin)",
1273
+ default: "origin",
1274
+ },
1275
+ } satisfies PolicyParamsSchema,
1276
+ },
1277
+ {
1278
+ name: "require-pr-before-stop",
1279
+ description: "Require a pull request to exist for the current branch before Claude stops",
1280
+ fn: requirePrBeforeStop,
1281
+ match: { events: ["Stop"] },
1282
+ defaultEnabled: false,
1283
+ category: "Workflow",
1284
+ beta: true,
1285
+ },
1286
+ {
1287
+ name: "require-ci-green-before-stop",
1288
+ description: "Require CI checks to pass on the current branch before Claude stops",
1289
+ fn: requireCiGreenBeforeStop,
1290
+ match: { events: ["Stop"] },
1291
+ defaultEnabled: false,
1292
+ category: "Workflow",
1293
+ beta: true,
1294
+ },
1056
1295
  ];
1057
1296
 
1058
1297
  export function registerBuiltinPolicies(enabledNames: string[]): void {