failproofai 0.0.2-beta.1 → 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 (132) 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 +1 -1
  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]__02nt~6d._.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]__0a3kr67._.js → [root-of-the-server]__07k6eu-._.js} +2 -2
  52. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  53. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  56. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rbuarm._.js → [root-of-the-server]__0kfv9fw._.js} +2 -2
  57. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0osi8nq._.js → [root-of-the-server]__0okos0k._.js} +3 -3
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +5 -4
  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 +1 -1
  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_next_0rd0oc-._.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/{0a08gn8709y98.js → 0.jo.465b6_k..js} +1 -1
  71. package/.next/standalone/.next/static/chunks/{0jhw8ofx.5g_e.js → 01haq0a3zrx0v.js} +1 -1
  72. package/.next/standalone/.next/static/chunks/08f78tecvx61l.css +1 -0
  73. package/.next/standalone/.next/static/chunks/{0mr-jhx402yci.js → 0a6xi1a8f_qlp.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/{0qvj8bhl661lq.js → 0mq7ze1vkeo1p.js} +1 -1
  75. package/.next/standalone/.next/static/chunks/{0gcz-jqgqz~9m.js → 0p_fpyfmmohnx.js} +1 -1
  76. package/.next/standalone/.next/static/chunks/{0kob_5.phc~sk.js → 0qwyj3m400l_g.js} +1 -1
  77. package/.next/standalone/.next/static/chunks/{0mjc3aq2wxvlt.js → 0t94r_mk0s7e4.js} +1 -1
  78. package/.next/standalone/.next/static/chunks/{0q7z97izctgrw.js → 139~00zc9.u7s.js} +1 -1
  79. package/.next/standalone/Dockerfile.docs +12 -0
  80. package/.next/standalone/README.md +68 -55
  81. package/.next/standalone/bin/failproofai.mjs +221 -128
  82. package/.next/standalone/dist/cli.mjs +415 -106
  83. package/.next/standalone/dist/index.js +2 -2
  84. package/.next/standalone/docs/{architecture.md → architecture.mdx} +40 -23
  85. package/.next/standalone/docs/{built-in-policies.md → built-in-policies.mdx} +134 -12
  86. package/.next/standalone/docs/cli/dashboard.mdx +28 -0
  87. package/.next/standalone/docs/cli/environment-variables.mdx +34 -0
  88. package/.next/standalone/docs/cli/hook.mdx +30 -0
  89. package/.next/standalone/docs/cli/install-policies.mdx +48 -0
  90. package/.next/standalone/docs/cli/list-policies.mdx +31 -0
  91. package/.next/standalone/docs/cli/remove-policies.mdx +44 -0
  92. package/.next/standalone/docs/cli/version.mdx +12 -0
  93. package/.next/standalone/docs/{configuration.md → configuration.mdx} +16 -16
  94. package/.next/standalone/docs/{custom-hooks.md → custom-policies.mdx} +80 -42
  95. package/.next/standalone/docs/{dashboard.md → dashboard.mdx} +26 -29
  96. package/.next/standalone/docs/docs.json +31 -4
  97. package/.next/standalone/docs/examples.mdx +253 -0
  98. package/.next/standalone/docs/for-agents.mdx +38 -0
  99. package/.next/standalone/docs/getting-started.mdx +134 -0
  100. package/.next/standalone/docs/introduction.mdx +57 -0
  101. package/.next/standalone/docs/logo/dark.svg +21 -0
  102. package/.next/standalone/docs/logo/light.svg +21 -0
  103. package/.next/standalone/docs/{package-aliases.md → package-aliases.mdx} +5 -5
  104. package/.next/standalone/docs/{testing.md → testing.mdx} +11 -11
  105. package/.next/standalone/package.json +6 -9
  106. package/.next/standalone/scripts/publish-aliases.mjs +4 -2
  107. package/.next/standalone/skills-lock.json +10 -0
  108. package/.next/standalone/src/cli-error.ts +18 -0
  109. package/.next/standalone/src/hooks/builtin-policies.ts +259 -20
  110. package/.next/standalone/src/hooks/manager.ts +17 -3
  111. package/.next/standalone/src/hooks/policy-evaluator.ts +19 -1
  112. package/.next/standalone/src/hooks/policy-helpers.ts +2 -2
  113. package/.next/standalone/vitest.config.e2e.mts +3 -0
  114. package/.next/standalone/vitest.config.mts +3 -0
  115. package/README.md +68 -55
  116. package/bin/failproofai.mjs +221 -128
  117. package/dist/cli.mjs +415 -106
  118. package/dist/index.js +2 -2
  119. package/package.json +6 -9
  120. package/scripts/publish-aliases.mjs +4 -2
  121. package/src/cli-error.ts +18 -0
  122. package/src/hooks/builtin-policies.ts +259 -20
  123. package/src/hooks/manager.ts +17 -3
  124. package/src/hooks/policy-evaluator.ts +19 -1
  125. package/src/hooks/policy-helpers.ts +2 -2
  126. package/.next/standalone/.next/static/chunks/15jpradyu_531.css +0 -1
  127. package/.next/standalone/docs/cli-reference.md +0 -175
  128. package/.next/standalone/docs/getting-started.md +0 -128
  129. package/.next/standalone/docs/introduction.md +0 -47
  130. /package/.next/standalone/.next/static/{Dnk96sbMPjYOx1pdLdOH0 → 7fR022u1Sj-s5MfKO1q9Y}/_buildManifest.js +0 -0
  131. /package/.next/standalone/.next/static/{Dnk96sbMPjYOx1pdLdOH0 → 7fR022u1Sj-s5MfKO1q9Y}/_clientMiddlewareManifest.js +0 -0
  132. /package/.next/standalone/.next/static/{Dnk96sbMPjYOx1pdLdOH0 → 7fR022u1Sj-s5MfKO1q9Y}/_ssgManifest.js +0 -0
@@ -17,7 +17,7 @@ bun run test:run
17
17
  # Run unit tests in watch mode
18
18
  bun run test
19
19
 
20
- # Run E2E tests (requires setup see below)
20
+ # Run E2E tests (requires setup - see below)
21
21
  bun run test:e2e
22
22
 
23
23
  # Type-check without building
@@ -33,7 +33,7 @@ bun run lint
33
33
 
34
34
  Unit tests live in `__tests__/` and use [Vitest](https://vitest.dev) with `happy-dom`.
35
35
 
36
- ```
36
+ ```text
37
37
  __tests__/
38
38
  hooks/
39
39
  builtin-policies.test.ts # Policy logic for each builtin
@@ -130,7 +130,7 @@ Rebuild `dist/` whenever you change the public hook API (`src/hooks/custom-hooks
130
130
 
131
131
  ### E2E test structure
132
132
 
133
- ```
133
+ ```text
134
134
  __tests__/e2e/
135
135
  helpers/
136
136
  hook-runner.ts # Spawn the binary, pipe payload JSON, capture exit code + stdout + stderr
@@ -145,14 +145,14 @@ __tests__/e2e/
145
145
 
146
146
  ### Using the E2E helpers
147
147
 
148
- **`FixtureEnv`** isolated per-test environment:
148
+ **`FixtureEnv`** - isolated per-test environment:
149
149
 
150
150
  ```typescript
151
151
  import { createFixtureEnv } from "../helpers/fixture-env";
152
152
 
153
153
  const env = createFixtureEnv();
154
- // env.cwd temp dir; pass as payload.cwd to pick up .failproofai/policies-config.json
155
- // env.home isolated home dir; no real ~/.failproofai leaks in
154
+ // env.cwd - temp dir; pass as payload.cwd to pick up .failproofai/policies-config.json
155
+ // env.home - isolated home dir; no real ~/.failproofai leaks in
156
156
 
157
157
  env.writeConfig({
158
158
  enabledPolicies: ["block-sudo"],
@@ -164,7 +164,7 @@ env.writeConfig({
164
164
 
165
165
  `createFixtureEnv()` registers `afterEach` cleanup automatically.
166
166
 
167
- **`runHook`** invoke the binary:
167
+ **`runHook`** - invoke the binary:
168
168
 
169
169
  ```typescript
170
170
  import { runHook } from "../helpers/hook-runner";
@@ -180,7 +180,7 @@ expect(result.exitCode).toBe(0);
180
180
  expect(result.parsed?.hookSpecificOutput?.permissionDecision).toBe("deny");
181
181
  ```
182
182
 
183
- **`Payloads`** ready-made payload factories:
183
+ **`Payloads`** - ready-made payload factories:
184
184
 
185
185
  ```typescript
186
186
  Payloads.preToolUse.bash(command, cwd)
@@ -245,9 +245,9 @@ describe("block-rm-rf (E2E)", () => {
245
245
 
246
246
  E2E tests use `vitest.config.e2e.mts` with:
247
247
 
248
- - `environment: "node"` no browser globals needed
249
- - `pool: "forks"` true process isolation (tests spawn subprocesses)
250
- - `testTimeout: 20_000` 20s per test (binary startup + hook eval)
248
+ - `environment: "node"` - no browser globals needed
249
+ - `pool: "forks"` - true process isolation (tests spawn subprocesses)
250
+ - `testTimeout: 20_000` - 20s per test (binary startup + hook eval)
251
251
 
252
252
  The `forks` pool is important: thread-based workers share `globalThis`, which can interfere with subprocess-spawning tests. Process-based forks avoid this.
253
253
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.2-beta.1",
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() ?? '');
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "skills": {
4
+ "mintlify": {
5
+ "source": "mintlify.com",
6
+ "sourceType": "well-known",
7
+ "computedHash": "d43d5294c5a569cf6d2878943309eca46f994544693b65f477a8216d3215c0b0"
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * CliError — structured error for the failproofai CLI.
3
+ *
4
+ * Throw this for any error that should be reported to the user as a clean
5
+ * message (no stack trace). The exit code communicates the failure class:
6
+ *
7
+ * 1 — user error (bad args, unknown policy name, invalid flag)
8
+ * 2 — internal (file I/O failure, unexpected state)
9
+ */
10
+ export class CliError extends Error {
11
+ readonly exitCode: 1 | 2;
12
+
13
+ constructor(message: string, exitCode: 1 | 2 = 1) {
14
+ super(message);
15
+ this.name = "CliError";
16
+ this.exitCode = exitCode;
17
+ }
18
+ }
@@ -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 {
@@ -21,6 +21,7 @@ import { BUILTIN_POLICIES } from "./builtin-policies";
21
21
  import { loadCustomHooks } from "./custom-hooks-loader";
22
22
  import { trackHookEvent } from "./hook-telemetry";
23
23
  import { getInstanceId, hashToId } from "../../lib/telemetry-id";
24
+ import { CliError } from "../cli-error";
24
25
 
25
26
  const VALID_POLICY_NAMES = new Set(BUILTIN_POLICIES.map((p) => p.name));
26
27
 
@@ -67,7 +68,7 @@ function resolveFailproofaiBinary(): string {
67
68
  // `where` on Windows may return multiple lines; take the first
68
69
  return result.split("\n")[0].trim();
69
70
  } catch {
70
- throw new Error(
71
+ throw new CliError(
71
72
  "failproofai binary not found in PATH.\n" +
72
73
  "Install it globally first: npm install -g failproofai"
73
74
  );
@@ -85,7 +86,7 @@ function validatePolicyNames(names: string[]): void {
85
86
  const invalid = names.filter((n) => !VALID_POLICY_NAMES.has(n));
86
87
  if (invalid.length > 0) {
87
88
  const validList = [...VALID_POLICY_NAMES].join(", ");
88
- throw new Error(
89
+ throw new CliError(
89
90
  `Unknown policy name(s): ${invalid.join(", ")}\n` +
90
91
  `Valid policies: ${validList}`
91
92
  );
@@ -185,6 +186,20 @@ export async function installHooks(
185
186
  customPoliciesPath?: string,
186
187
  removeCustomHooks = false,
187
188
  ): Promise<void> {
189
+ // Validate user input first before any system checks
190
+ if (policyNames !== undefined && policyNames.length > 0) {
191
+ const nonAllNames = policyNames.filter((n) => n !== "all");
192
+ // Check unknown names first (most actionable error for the user)
193
+ if (nonAllNames.length > 0) validatePolicyNames(nonAllNames);
194
+ // Then check if "all" is mixed with valid specific names
195
+ if (policyNames.includes("all") && nonAllNames.length > 0) {
196
+ throw new CliError(
197
+ `"all" cannot be combined with specific policy names.\n` +
198
+ `Use either: --install all or --install block-sudo sanitize-jwt ...`
199
+ );
200
+ }
201
+ }
202
+
188
203
  const binaryPath = resolveFailproofaiBinary();
189
204
 
190
205
  // Capture existing config before overwriting (used for telemetry diff)
@@ -201,7 +216,6 @@ export async function installHooks(
201
216
  .filter((p) => includeBeta || !p.beta)
202
217
  .map((p) => p.name);
203
218
  } else {
204
- if (policyNames.length > 0) validatePolicyNames(policyNames);
205
219
  incoming = policyNames;
206
220
  }
207
221
  // Additive: union with whatever was already enabled, deduplicated.
@@ -51,6 +51,9 @@ export async function evaluatePolicies(
51
51
  let instructPolicyName: string | null = null;
52
52
  let instructReason: string | null = null;
53
53
 
54
+ // Track informational messages from allow decisions
55
+ const allowMessages: string[] = [];
56
+
54
57
  for (const policy of policies) {
55
58
  // Inject params: merge policyParams[policy.name] over schema defaults
56
59
  const schema = POLICY_PARAMS_MAP.get(policy.name);
@@ -133,6 +136,11 @@ export async function evaluatePolicies(
133
136
  instructReason = result.reason ?? `Instruction from policy: ${policy.name}`;
134
137
  hookLogInfo(`instruct by "${policy.name}": ${instructReason}`);
135
138
  }
139
+
140
+ // Accumulate informational messages from allow decisions
141
+ if (result.decision === "allow" && result.reason) {
142
+ allowMessages.push(result.reason);
143
+ }
136
144
  }
137
145
 
138
146
  // No deny — check if we accumulated an instruct
@@ -166,6 +174,16 @@ export async function evaluatePolicies(
166
174
  };
167
175
  }
168
176
 
169
- // All policies allowed
177
+ // All policies allowed — pass along any informational messages
178
+ if (allowMessages.length > 0) {
179
+ const combined = allowMessages.join("\n");
180
+ const response = {
181
+ hookSpecificOutput: {
182
+ hookEventName: eventType,
183
+ additionalContext: combined,
184
+ },
185
+ };
186
+ return { exitCode: 0, stdout: JSON.stringify(response), stderr: "", policyName: null, reason: combined, decision: "allow" };
187
+ }
170
188
  return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
171
189
  }
@@ -3,8 +3,8 @@
3
3
  */
4
4
  import type { PolicyResult } from "./policy-types";
5
5
 
6
- export function allow(): PolicyResult {
7
- return { decision: "allow" };
6
+ export function allow(reason?: string): PolicyResult {
7
+ return reason ? { decision: "allow", reason } : { decision: "allow" };
8
8
  }
9
9
 
10
10
  export function deny(reason: string): PolicyResult {
@@ -17,5 +17,8 @@ export default defineConfig({
17
17
  // forks pool: true process isolation — tests spawn subprocesses,
18
18
  // thread workers share globalThis which can interfere.
19
19
  pool: "forks",
20
+ env: {
21
+ FAILPROOFAI_TELEMETRY_DISABLED: "1",
22
+ },
20
23
  },
21
24
  });
@@ -16,5 +16,8 @@ export default defineConfig({
16
16
  include: ["__tests__/**/*.test.{ts,tsx}"],
17
17
  exclude: ["__tests__/e2e/**"],
18
18
  css: false,
19
+ env: {
20
+ FAILPROOFAI_TELEMETRY_DISABLED: "1",
21
+ },
19
22
  },
20
23
  });