From d9b36b30c31552b514bd3b4cc437acc29e7d3ac2 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 10 Jul 2026 23:35:31 -0700 Subject: [PATCH 1/2] [failproofai-487] Skip require-*-before-stop gates in Claude Code plan mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five require-*-before-stop workflow gates fired on every Stop event without checking the session's permission mode. In Claude Code plan mode (permission_mode: "plan") the agent makes no commits/pushes/PRs by design, so the gates demanded actions plan mode forbids — e.g. "git push" with nothing to push — blocking the agent from finishing its turn. permission_mode is already resolved onto ctx.session.permissionMode (resolve-permission-mode.ts -> handler.ts) but was only consumed for activity logging and the dashboard badge. Add an isPlanMode(ctx) guard as the first statement of each of the five policies (commit/push/pr/ no-conflicts/ci-green) so they skip in plan mode and stay fully active in default sessions; custom Stop policies are untouched. Adds a plan-mode regression test to each policy suite asserting allow + that no git/gh subprocess runs (guard short-circuits before any exec). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012Gn9p2tji5JjqAWAVg23ds --- CHANGELOG.md | 5 ++ __tests__/hooks/builtin-policies.test.ts | 59 ++++++++++++++++++++++++ src/hooks/builtin-policies.ts | 16 +++++++ 3 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d0524b..72d3ac4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.13-beta.2 — 2026-07-10 + +### Fixes +- Skip the `require-*-before-stop` workflow gates during Claude Code plan mode (`permission_mode: "plan"`) — plan mode makes no commits/pushes/PRs by design, so the gates were wrongly demanding actions plan mode forbids (e.g. `git push` with nothing to push, blocking the agent from finishing). (#487) + ## 0.0.13-beta.1 — 2026-07-10 ### Features diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 3aae34a0..e0c084d9 100644 --- a/__tests__/hooks/builtin-policies.test.ts +++ b/__tests__/hooks/builtin-policies.test.ts @@ -2303,6 +2303,18 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("uncommitted changes"); }); + it("allows in plan mode without running git (research-only, nothing to commit)", async () => { + // Regression: Stop workflow gates must not fire in Claude Code plan mode, + // where the agent makes no changes by design. The guard short-circuits + // before any git subprocess runs, even when the tree looks dirty. + vi.mocked(execSync).mockReturnValue("M src/index.ts\n"); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo", permissionMode: "plan" } }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + expect(result.reason).toContain("Plan mode"); + expect(execSync).not.toHaveBeenCalled(); + }); + it("denies when there are untracked files", async () => { vi.mocked(execSync).mockReturnValue("?? newfile.ts\n"); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); @@ -2471,6 +2483,18 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("new-feature"); }); + it("allows in plan mode even when the branch was never pushed (the reported bug)", async () => { + // Regression for the plan-mode false positive: a plan-only turn makes no + // commits/pushes, so requiring `git push -u` before stop is a contradiction. + mockPushScenario({ branch: "new-feature", hasTracking: false }); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo", permissionMode: "plan" } }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + expect(result.reason).toContain("Plan mode"); + expect(execSync).not.toHaveBeenCalled(); + expect(execFileSync).not.toHaveBeenCalled(); + }); + it("deny message includes branch name and remote", async () => { mockPushScenario({ branch: "my-feature", hasTracking: false }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); @@ -2687,6 +2711,16 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("gh pr create"); }); + it("allows in plan mode without checking for a PR (research-only)", async () => { + mockPrScenario({ prResult: null }); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo", permissionMode: "plan" } }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + expect(result.reason).toContain("Plan mode"); + expect(execSync).not.toHaveBeenCalled(); + expect(execFileSync).not.toHaveBeenCalled(); + }); + it("deny message includes the branch name", async () => { mockPrScenario({ branch: "my-feature", prResult: null }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); @@ -3013,6 +3047,19 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("Rebase or merge origin/main"); }); + it("allows in plan mode without probing for conflicts (research-only)", async () => { + mockConflictsScenario({ + mergeTreeStatus: 1, + mergeTreeStdout: "treeoid123\nsrc/app.ts\n\nCONFLICT (content): ...\n", + }); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo", permissionMode: "plan" } }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + expect(result.reason).toContain("Plan mode"); + expect(execSync).not.toHaveBeenCalled(); + expect(execFileSync).not.toHaveBeenCalled(); + }); + it("deny reason falls back to \"one or more files\" when stdout parse yields no files", async () => { mockConflictsScenario({ mergeTreeStatus: 1, @@ -3259,6 +3306,18 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain('"test"'); }); + it("allows in plan mode without querying CI (research-only)", async () => { + mockCiScenario("feat/branch", JSON.stringify([ + { status: "completed", conclusion: "failure", name: "test" }, + ])); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo", permissionMode: "plan" } }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + expect(result.reason).toContain("Plan mode"); + expect(execSync).not.toHaveBeenCalled(); + expect(execFileSync).not.toHaveBeenCalled(); + }); + it("denies listing multiple failed checks by name", async () => { mockCiScenario("feat/branch", JSON.stringify([ { status: "completed", conclusion: "failure", name: "test" }, diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts index 30eaa8c3..d57fc5fa 100644 --- a/src/hooks/builtin-policies.ts +++ b/src/hooks/builtin-policies.ts @@ -1088,7 +1088,19 @@ function warnBackgroundProcess(ctx: PolicyContext): PolicyResult { // -- Workflow (Stop event) policies -- +/** + * Claude Code plan mode (permission_mode: "plan") is research-and-plan-only — the + * agent makes no commits, pushes, or PRs by design. The Stop-workflow gates below + * all assume the agent produced code changes, so in plan mode they demand actions + * plan mode forbids (e.g. a push with nothing to push). Skip them there. Only Claude + * reports "plan" today; every other CLI resolves to "default". + */ +function isPlanMode(ctx: PolicyContext): boolean { + return ctx.session?.permissionMode === "plan"; +} + function requireCommitBeforeStop(ctx: PolicyContext): PolicyResult { + if (isPlanMode(ctx)) return allow("Plan mode — no changes made, skipping commit check."); const cwd = ctx.session?.cwd; if (!cwd) return allow("No working directory available, skipping commit check."); @@ -1111,6 +1123,7 @@ function requireCommitBeforeStop(ctx: PolicyContext): PolicyResult { } function requirePushBeforeStop(ctx: PolicyContext): PolicyResult { + if (isPlanMode(ctx)) return allow("Plan mode — no changes made, skipping push check."); const cwd = ctx.session?.cwd; if (!cwd) return allow("No working directory available, skipping push check."); @@ -1205,6 +1218,7 @@ function requirePushBeforeStop(ctx: PolicyContext): PolicyResult { } function requirePrBeforeStop(ctx: PolicyContext): PolicyResult { + if (isPlanMode(ctx)) return allow("Plan mode — no changes made, skipping PR check."); const cwd = ctx.session?.cwd; if (!cwd) return allow("No working directory available, skipping PR check."); @@ -1298,6 +1312,7 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult { } function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult { + if (isPlanMode(ctx)) return allow("Plan mode — no changes made, skipping conflict check."); const cwd = ctx.session?.cwd; if (!cwd) return allow("No working directory available, skipping conflict check."); @@ -1396,6 +1411,7 @@ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult { } function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult { + if (isPlanMode(ctx)) return allow("Plan mode — no changes made, skipping CI check."); const cwd = ctx.session?.cwd; if (!cwd) return allow("No working directory available, skipping CI check."); From 94c3fbdcbddb635225b8355c8a974a3614c6e41b Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 10 Jul 2026 23:38:25 -0700 Subject: [PATCH 2/2] [failproofai-487] Correct changelog PR reference to #488 The PR landed as #488 (the predicted #487 was taken in between). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012Gn9p2tji5JjqAWAVg23ds --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d3ac4c..6f2ea5a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.0.13-beta.2 — 2026-07-10 ### Fixes -- Skip the `require-*-before-stop` workflow gates during Claude Code plan mode (`permission_mode: "plan"`) — plan mode makes no commits/pushes/PRs by design, so the gates were wrongly demanding actions plan mode forbids (e.g. `git push` with nothing to push, blocking the agent from finishing). (#487) +- Skip the `require-*-before-stop` workflow gates during Claude Code plan mode (`permission_mode: "plan"`) — plan mode makes no commits/pushes/PRs by design, so the gates were wrongly demanding actions plan mode forbids (e.g. `git push` with nothing to push, blocking the agent from finishing). (#488) ## 0.0.13-beta.1 — 2026-07-10