diff --git a/CHANGELOG.md b/CHANGELOG.md index 32422230..064f58d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog -## 0.0.13-beta.2 — 2026-07-11 +## 0.0.13-beta.2 — 2026-07-10 -### Docs +### 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). (#488) - Fix translation regressions from the #486 docs sync: strip hallucinated `{#…}` heading-ID syntax that broke MDX parsing (`ja/built-in-policies`), restore dropped YAML frontmatter (`tr/cli/hook`) and the `--cli …|hermes` inline code span (`he/configuration`), and un-translate an inline-code placeholder (`de/cli/auth`). (#487) ## 0.0.13-beta.1 — 2026-07-10 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.");