Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Fixes
- Strengthen Stop-event deny/instruct instructions with mandatory framing so agents execute required actions instead of asking for confirmation (#109)

## 0.0.4 — 2026-04-16

### Features
Expand Down
12 changes: 8 additions & 4 deletions __tests__/hooks/policy-evaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ describe("hooks/policy-evaluator", () => {
expect(result.exitCode).toBe(2);
expect(result.decision).toBe("instruct");
expect(result.stdout).toBe("");
expect(result.stderr).toBe("Unsatisfied intents remain");
expect(result.stderr).toContain("MANDATORY ACTION REQUIRED");
expect(result.stderr).toContain("Unsatisfied intents remain");
expect(result.policyName).toBe("verify");
});

Expand Down Expand Up @@ -350,7 +351,8 @@ describe("hooks/policy-evaluator", () => {
const result = await evaluatePolicies("Stop", {});
expect(result.exitCode).toBe(2);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("changes not committed");
expect(result.stderr).toContain("MANDATORY ACTION REQUIRED");
expect(result.stderr).toContain("changes not committed");
expect(result.decision).toBe("deny");
expect(result.reason).toBe("changes not committed");
});
Expand Down Expand Up @@ -452,7 +454,8 @@ describe("hooks/policy-evaluator", () => {
const result = await evaluatePolicies("Stop", {});
expect(result.decision).toBe("instruct");
expect(result.exitCode).toBe(2);
expect(result.stderr).toBe("Please verify tests");
expect(result.stderr).toContain("MANDATORY ACTION REQUIRED");
expect(result.stderr).toContain("Please verify tests");
});

it("mixed allow (no message) and allow (with message) — only messages returned", async () => {
Expand Down Expand Up @@ -570,7 +573,8 @@ describe("hooks/policy-evaluator", () => {
expect(result.exitCode).toBe(2);
expect(result.decision).toBe("instruct");
expect(result.reason).toBe("Unsatisfied intents. Run the test suite first.");
expect(result.stderr).toBe("Unsatisfied intents. Run the test suite first.");
expect(result.stderr).toContain("MANDATORY ACTION REQUIRED");
expect(result.stderr).toContain("Unsatisfied intents. Run the test suite first.");
});

it("does not alter reason when no hint is configured", async () => {
Expand Down
14 changes: 7 additions & 7 deletions src/hooks/builtin-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ function requireCommitBeforeStop(ctx: PolicyContext): PolicyResult {

if (status.length > 0) {
return deny(
"You have uncommitted changes in the working directory. Commit all changes before stopping.",
"You have uncommitted changes in the working directory. Commit all changes now.",
);
}
return allow("All changes are committed.");
Expand Down Expand Up @@ -937,7 +937,7 @@ function requirePushBeforeStop(ctx: PolicyContext): PolicyResult {
if (!hasTracking) {
return deny(
`Branch "${branch}" has not been pushed to remote "${remote}". ` +
`Push your branch with: git push -u ${remote} ${branch}`,
`Run now: git push -u ${remote} ${branch}`,
);
}

Expand All @@ -952,7 +952,7 @@ function requirePushBeforeStop(ctx: PolicyContext): PolicyResult {
const commitCount = unpushed.split("\n").length;
return deny(
`You have ${commitCount} unpushed commit${commitCount > 1 ? "s" : ""} on branch "${branch}". ` +
`Push your changes with: git push`,
`Run now: git push`,
);
}

Expand Down Expand Up @@ -1024,7 +1024,7 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
// gh pr view exits non-zero when no PR exists
return deny(
`No pull request found for branch "${branch}". ` +
`Create one with: gh pr create`,
`Run now: gh pr create`,
);
}

Expand All @@ -1035,7 +1035,7 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
}

return deny(
`Pull request for branch "${branch}" is ${pr.state.toLowerCase()}. Create a new PR with: gh pr create`,
`Pull request for branch "${branch}" is ${pr.state.toLowerCase()}. Run now: gh pr create`,
);
} catch {
return allow("Could not check PR status, skipping.");
Expand Down Expand Up @@ -1091,7 +1091,7 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
if (failing.length > 0) {
const names = failing.map((r) => `"${r.name}"`).join(", ");
return deny(
`CI checks are failing on branch "${branch}": ${names}. Fix the failing checks before stopping.`,
`CI checks are failing on branch "${branch}": ${names}. Fix the failing checks now.`,
);
}

Expand All @@ -1101,7 +1101,7 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
if (pending.length > 0) {
const names = pending.map((r) => `"${r.name}"`).join(", ");
return deny(
`CI checks are still running on branch "${branch}": ${names}. Wait for all checks to complete and verify they pass.`,
`CI checks are still running on branch "${branch}": ${names}. Wait for all checks to complete, then verify they pass.`,
);
}

Expand Down
16 changes: 15 additions & 1 deletion src/hooks/policy-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ export async function evaluatePolicies(
};
}

if (eventType === "Stop") {
return {
exitCode: 2,
stdout: "",
stderr: `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`,
policyName: policy.name,
reason,
decision: "deny",
};
}

// Other event types: exit 2
return {
exitCode: 2,
Expand Down Expand Up @@ -165,10 +176,13 @@ export async function evaluatePolicies(
if (eventType === "Stop") {
// Stop hook: exitCode 2 blocks Claude from stopping.
// Reason goes to stderr so Claude Code receives it as context.
const policyAttribution = policyNames.length === 1
? `policy: ${policyNames[0]}`
: `policies: ${policyNames.join(", ")}`;
return {
exitCode: 2,
stdout: "",
stderr: combined,
stderr: `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`,
policyName: policyNames[0],
policyNames,
reason: combined,
Expand Down
Loading