Skip to content

Commit bbbdc8d

Browse files
NiveditJainclaude
andauthored
[luv-319] fix: enforce Stop hook on Cursor Agent CLI + cut 0.0.10-beta.6 (#318)
* [luv-319] fix: enforce Stop hook on Cursor Agent CLI (followup_message + SubagentStop parity) Cursor's `stop` hook ignores the flat `{permission: "deny"}` shape — that's honored on tool events only. The only force-retry channel for Stop is `{followup_message}` on stdout (exit 0), per https://cursor.com/docs/hooks. The instruct branch already used this shape correctly since #245; the deny path needed the same treatment, mirroring Copilot's #299 fix. Without this, the 5 require-*-before-stop builtins were observation-only on Cursor — the deny was logged but the agent stopped cleanly. User repro: session 1b510ad4-906c-4f30-9467-ff2e6c581cce at /home/nivedit/dev-purge. Also subscribes to `subagentStop` (CURSOR_HOOK_EVENT_TYPES + CURSOR_EVENT_MAP) and widens both deny and instruct branches to match it, for parity with the Copilot SubagentStop widening from #299. Cloud Agents caveat: Cursor Cloud Agent VMs do NOT run stop/subagentStop hooks at all, so this fix only covers local Cursor sessions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: cut 0.0.10-beta.6 release in CHANGELOG Promote the six entries accumulated under `## Unreleased` to a versioned heading `## 0.0.10-beta.6 — 2026-05-08`. Add a fresh `## Unreleased` heading at the top for the next development cycle. package.json was already at 0.0.10-beta.6 (pre-bumped); no version edit needed here. The CHANGELOG cut completes the release-prep handshake. Entries promoted: - Cursor Stop hook enforcement fix (this PR) - 5 scripts/translate-docs fixes from #305, #306, #307, #312, #313 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c4506b9 commit bbbdc8d

9 files changed

Lines changed: 248 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
## Unreleased
44

5+
## 0.0.10-beta.6 — 2026-05-08
6+
57
### Fixes
8+
- Make `require-*-before-stop` policies actually enforce on Cursor Agent CLI (and add `SubagentStop` parity). Verified empirically: a `stop` hook emitting Cursor's `{permission: "deny", user_message, agent_message}` flat shape is silently ignored — that shape is honored on tool events only, and on Stop the agent stops cleanly without retry. Per https://cursor.com/docs/hooks the only force-retry channel for `stop` / `subagentStop` is `{followup_message: "<text>"}` on stdout (exit 0), with the text auto-submitted as the next user message (capped at `loop_limit`, default 5). New `cli === "cursor" && eventType in {Stop, SubagentStop}` arm inside the Cursor deny branch in `src/hooks/policy-evaluator.ts` emits that shape ahead of the existing flat-shape return for tool events, mirroring the Cursor Stop instruct branch already at `:336` (which had used `{followup_message}` correctly since #245), the Copilot Stop branch added in #299 at `:279`, and the Gemini AfterAgent branch at `:188`. Without this arm, all 5 `require-*-before-stop` builtins (commit / push / PR / no-conflicts / CI-green) were observation-only on Cursor — exact same failure mode as Copilot pre-#299. Also adds `subagentStop` to `CURSOR_HOOK_EVENT_TYPES` + `CURSOR_EVENT_MAP` so **custom** policies subscribing to `SubagentStop` are reachable from Cursor subagent boundaries (Cursor's `subagentStop` is a sibling of `stop`, same payload + response contract); the instruct branch at `:336` is widened to match both events for parity. The 5 `require-*-before-stop` builtins still match `Stop` only by design — they are session-completion gates (commit / push / PR / conflicts / CI), not subagent-return gates — so the SubagentStop widening does not change builtin behavior. Caveat: Cursor Cloud Agent VMs do NOT run `stop` / `subagentStop` hooks at all (forum-confirmed at <https://forum.cursor.com/t/cursor-cloud-agents-do-not-run-afteragentresponse-or-stop-hooks/159929>) — this fix only covers local Cursor sessions; failproofai cannot enforce Stop policies in Cloud Agent runs. New unit tests pin the Cursor Stop and SubagentStop deny / instruct response shapes (4 tests across `policy-evaluator.test.ts`); new e2e regression in `cursor-integration.e2e.test.ts` confirms `require-commit-before-stop` against a dirty real-git fixture round-trips to the `{followup_message}` shape; existing data-driven `writeHookEntries` and `removeHooksFromFile` tests in `integrations.test.ts` auto-extend to `subagentStop` via iteration over `CURSOR_HOOK_EVENT_TYPES`. Updated `CLAUDE.md` Cursor section with a verified Stop block semantics table and the Cloud Agents caveat.
69
- `scripts/translate-docs/mdx-translator.ts`: new `stripStrayTrailingFence` helper, wired into both `translateMdxPage` and `translateReadme`, drops a stray trailing ` ``` ` line that streamed Sonnet runs of long pages sometimes append. The unmatched fence opens a code block that consumes everything to EOF — including the wrapping `</div>` for RTL READMEs — and surfaces in Mintlify as `Failed to parse page content at path i18n/README.he.md: Expected a closing tag for <div> (6:1-6:16)`. Empirically observed on run 25542951106 (post-streaming-switch #307): `docs/i18n/README.he.md` and `docs/i18n/README.tr.md` both ended with 31 fence-line markers (one stray) instead of the canonical 30; a subsequent rebase against main found `docs/i18n/README.ar.md` regenerated by the auto-translate workflow (#312) with the same bug. The helper detects the odd-count case and removes only the last unmatched fence, preserving every balanced pair before it. Also strips the stray trailing fence from all three affected files in this commit so Mintlify can deploy without a re-translate. Six-case unit test covers balanced-unchanged, no-fence-unchanged, stray-trailing-after-balanced-pair, lone-fence, embedded-non-fence-mid-line, and language-tagged pairs (#313).
710
- `scripts/translate-docs/translator.ts`: switch `translateContent` from `anthropic.messages.create(...)` to `anthropic.messages.stream(...).finalMessage()` so large Tier-1 (Sonnet) translations don't hit AWS Bedrock's 300 s synchronous `InvokeModel` ceiling. The LiteLLM proxy at `models.aikin.club` routes `claude-sonnet-4-6` weighted 1:1 across `anthropic/claude-sonnet-4-6` and `bedrock/us.anthropic.claude-sonnet-4-6`; under translate-docs load (4 jobs × 4 in-flight = 16 concurrent) any request that lands on Bedrock and runs >300 s is severed by Bedrock and surfaces to the SDK as `APIConnectionError ("Connection error.")` — exactly the symptom that survived #306 (SDK retry bump) and the platform-side `request_timeout: 300 → 600` lift in `exospherehost/platform#345`. Two consecutive matrix runs post-platform-fix ([25540656053](https://github.com/exospherehost/failproofai/actions/runs/25540656053), [25541614351](https://github.com/exospherehost/failproofai/actions/runs/25541614351)) showed the same deterministic failure cohort: the 4 largest pages (`built-in-policies`, `architecture`, `configuration`, `custom-policies`, plus `README`) failing at ~317 s for in-flight slot 1/2 and ~367 s for slots 3/4 — both below the new 600 s ceiling, so the wall isn't ours. `messages.stream(...).finalMessage()` returns the same `Message` shape so the function's public return type is unchanged; Bedrock falls back to `InvokeModelWithResponseStream` (no 300 s wall) and Anthropic-direct supports streaming for the full 10-minute non-streaming budget. SDK `maxRetries: 5`, per-job `MAX_CONCURRENT: 4`, and the platform `request_timeout: 600 s` ceiling all stay as the correct safety bounds; the actual unblock was on the client-side request shape (#307).
811
- `scripts/translate-docs`: bump SDK `maxRetries` from the Anthropic default of 2 to 5 in `translator.ts:getClient` and raise per-job `MAX_CONCURRENT` from 2 to 4 in `cli.ts`, both now env-overridable via `TRANSLATE_MAX_RETRIES` and `TRANSLATE_MAX_CONCURRENT`. The LiteLLM proxy behind `ANTHROPIC_BASE_URL` has been horizontally scaled, so the previous cap of 2 (set in #300 to dodge the gateway's connection-drop cliff at ~2 in flight) now leaves capacity on the floor. The errors that *do* still surface are no longer load-induced — they are per-request transient failures (cold replicas, LB hashing landing on an unhealthy pod, idle-socket TCP resets) where the SDK's default 2-retry budget runs out before the LB can route a retry to a healthy replica, and `Anthropic.APIConnectionError ("Connection error.")` bubbles up. Empirically observed: a `--languages zh --force` re-run (Tier-1 Sonnet, 5 uncached MDX pages) returned 2 successes and 2 `Connection error.` lines under the prior 2/2 setting. Bumping to 5 retries (≈0.5+1+2+4+8 ≈ 15 s of jittered backoff per request, 6 connection attempts total per page) absorbs the transient failures; bumping concurrency to 4 takes back the throughput the prior cap forfeited. CI matrix `max-parallel: 4` is unchanged — the new global ceiling of 4×4 = 16 in flight is still half the failure-mode threshold of 28 from #305 even before accounting for the scale-out, so no workflow change needed (#306).

CLAUDE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,33 @@ which writes a portable `npx -y failproofai --hook ... --cli cursor` command.
114114
Same self-reference caveat applies — do **not** install the standard `npx`
115115
form from inside this repo.
116116

117+
**Stop block semantics** (verified against cursor-agent docs as of 2026-05-08
118+
and live behavior):
119+
120+
| Channel | Effect |
121+
|------------------------------------------------------|-------------------------------------------------------------------------------------------------|
122+
| `{followup_message: "<text>"}` JSON stdout (exit 0) | ✅ Forces another turn — text becomes next user message; capped at `loop_limit` (default 5) |
123+
| `{permission: "deny", …}` JSON stdout (exit 0) | ❌ Honored on tool events only — Stop falls through and agent stops cleanly |
124+
| Exit 2 + stderr (Claude convention) | ❌ Surfaced as warning but does NOT trigger retry |
125+
126+
policy-evaluator.ts has a `cli === "cursor" && eventType in {Stop, SubagentStop}`
127+
branch ahead of the generic Cursor flat-shape deny that emits the
128+
`{followup_message}` shape, so the 5 `require-*-before-stop` builtins
129+
actually enforce on Cursor. Same shape applies to SubagentStop (Cursor's
130+
`subagentStop` is a sibling of `stop`, same payload + response contract);
131+
we subscribe to it for parity with Copilot so custom policies subscribing
132+
to SubagentStop also enforce on Cursor subagent boundaries. The 5
133+
`require-*-before-stop` builtins still match `Stop` only by design —
134+
session-completion gates, not subagent-return gates.
135+
136+
**Cloud Agents caveat:** Cursor Cloud Agent VMs do NOT run `stop` /
137+
`subagentStop` hooks (or `afterAgentResponse`) — confirmed via Cursor
138+
forum: <https://forum.cursor.com/t/cursor-cloud-agents-do-not-run-afteragentresponse-or-stop-hooks/159929>.
139+
This means failproofai cannot enforce Stop policies in Cursor Cloud Agent
140+
runs; the fix above only covers local Cursor sessions.
141+
142+
Ref: <https://cursor.com/docs/hooks>
143+
117144
### OpenCode hooks (`.opencode/`)
118145

119146
This repo also ships a project-scope OpenCode (sst/opencode) plugin

__tests__/e2e/helpers/hook-runner.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,17 @@ export function assertCursorStopInstruct(result: HookRunResult): void {
140140
expect(result.parsed?.followup_message).toMatch(/^Instruction from failproofai:/);
141141
}
142142

143+
export function assertCursorStopBlock(result: HookRunResult): void {
144+
// Cursor's stop / subagentStop hooks honor `{followup_message}` on stdout
145+
// (exit 0) — auto-submitted as next user message, capped at loop_limit
146+
// (default 5). The flat `{permission: "deny"}` shape is ignored on Stop.
147+
// Mirrors assertCopilotStopBlock and assertGeminiStopBlock.
148+
// Ref: https://cursor.com/docs/hooks
149+
expect(result.exitCode).toBe(0);
150+
expect(typeof result.parsed?.followup_message).toBe("string");
151+
expect(result.parsed?.followup_message).toMatch(/MANDATORY ACTION REQUIRED/);
152+
}
153+
143154
/**
144155
* Pi emits a flat `{permission, reason}` JSON shape — the pi-extension shim
145156
* parses this and translates `permission === "deny"` into a `{block, reason}`

__tests__/e2e/helpers/payloads.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,14 @@ export const CursorPayloads = {
290290
hook_event_name: "stop",
291291
};
292292
},
293+
subagentStop(cwd: string): Record<string, unknown> {
294+
return {
295+
conversation_id: CURSOR_CONVERSATION_ID,
296+
transcript_path: TRANSCRIPT_PATH,
297+
workspace_roots: [cwd],
298+
hook_event_name: "subagentStop",
299+
};
300+
},
293301
};
294302

295303
/**

__tests__/e2e/hooks/cursor-integration.e2e.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
runHook,
1616
assertAllow,
1717
assertCursorDeny,
18+
assertCursorStopBlock,
1819
} from "../helpers/hook-runner";
1920
import { CursorPayloads } from "../helpers/payloads";
2021

@@ -181,6 +182,72 @@ describe("E2E: Cursor integration — hook protocol", () => {
181182
env.cleanup();
182183
}
183184
});
185+
186+
// Stop hook on Cursor honors `{followup_message}` JSON, NOT `{permission:
187+
// "deny"}` (which is a tool-event-only shape) and NOT exit-2+stderr (which
188+
// Cursor surfaces as a warning but ignores for retry). Per
189+
// https://cursor.com/docs/hooks the followup_message text is auto-submitted
190+
// as the next user message, capped at `loop_limit` (default 5). The
191+
// `cli === "cursor" && eventType in {Stop, SubagentStop}` branch in
192+
// policy-evaluator.ts emits this shape; without it the 5
193+
// require-*-before-stop builtins were observation-only on Cursor.
194+
it("stop deny emits {followup_message} JSON (Cursor stop force-retry shape)", () => {
195+
const env = createCursorEnv();
196+
try {
197+
writeConfig(env.cwd, ["require-commit-before-stop"]);
198+
// require-commit-before-stop denies when the cwd has uncommitted changes.
199+
// env.cwd has no .git/ at all, so the policy short-circuits to allow —
200+
// we need to plant uncommitted state. Same pattern as the Copilot Stop
201+
// e2e in copilot-integration.e2e.test.ts.
202+
execSync(
203+
"git init -q && git config user.email t@t && git config user.name t && touch tracked && git add tracked && git commit -q -m initial && echo dirty > tracked",
204+
{ cwd: env.cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
205+
);
206+
const result = runHook(
207+
"stop",
208+
CursorPayloads.stop(env.cwd),
209+
{ homeDir: env.home, cli: "cursor" },
210+
);
211+
assertCursorStopBlock(result);
212+
// The flat {permission: "deny"} shape MUST NOT leak through on Stop —
213+
// Cursor would ignore it and the agent would stop cleanly.
214+
expect(result.parsed?.permission).toBeUndefined();
215+
} finally {
216+
env.cleanup();
217+
}
218+
});
219+
220+
// SubagentStop is a sibling of stop with the same payload + response
221+
// contract per Cursor docs; we subscribe to it (CURSOR_HOOK_EVENT_TYPES,
222+
// CURSOR_EVENT_MAP) so custom policies matching SubagentStop are reachable
223+
// from Cursor subagent boundaries — parity with Copilot's #299 widening.
224+
// Builtin require-*-before-stop policies still match Stop only by design,
225+
// so we exercise SubagentStop with a custom policy via a small inline shim:
226+
// assert allow when no SubagentStop policy is enabled but the hook fires
227+
// and is canonicalized correctly (event lands in activity store as
228+
// SubagentStop, not as an unknown camelCase form).
229+
it("subagentStop canonicalizes to SubagentStop and reaches the activity store", () => {
230+
const env = createCursorEnv();
231+
try {
232+
writeConfig(env.cwd, []);
233+
const result = runHook(
234+
"subagentStop",
235+
CursorPayloads.subagentStop(env.cwd),
236+
{ homeDir: env.home, cli: "cursor" },
237+
);
238+
assertAllow(result);
239+
240+
const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl");
241+
expect(existsSync(activityPath)).toBe(true);
242+
const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean);
243+
const last = JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
244+
expect(last.integration).toBe("cursor");
245+
expect(last.eventType).toBe("SubagentStop");
246+
expect(last.cwd).toBe(env.cwd);
247+
} finally {
248+
env.cleanup();
249+
}
250+
});
184251
});
185252

186253
describe("E2E: Cursor integration — install/uninstall", () => {
@@ -203,6 +270,9 @@ describe("E2E: Cursor integration — install/uninstall", () => {
203270
expect(hooks.sessionEnd).toBeDefined();
204271
expect(hooks.beforeSubmitPrompt).toBeDefined();
205272
expect(hooks.stop).toBeDefined();
273+
// subagentStop subscribed since #NEW for Copilot-parity: custom policies
274+
// matching SubagentStop are reachable on Cursor subagent boundaries.
275+
expect(hooks.subagentStop).toBeDefined();
206276
// PascalCase keys should not be present.
207277
expect(hooks.PreToolUse).toBeUndefined();
208278
// Flat array — each entry IS the hook, no `{hooks: [...]}` matcher wrapper.

__tests__/hooks/integrations.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,9 @@ describe("Cursor Agent integration", () => {
392392
expect(cursor.eventTypes).toContain("sessionStart");
393393
expect(cursor.eventTypes).toContain("sessionEnd");
394394
expect(cursor.eventTypes).toContain("stop");
395+
// subagentStop subscribed for parity with Copilot — custom policies
396+
// matching SubagentStop are reachable on Cursor subagent boundaries.
397+
expect(cursor.eventTypes).toContain("subagentStop");
395398
});
396399

397400
it("buildHookEntry uses Claude-shaped {command,timeout} with --cli cursor", () => {
@@ -480,6 +483,7 @@ describe("CURSOR_EVENT_MAP", () => {
480483
expect(CURSOR_EVENT_MAP.sessionStart).toBe("SessionStart");
481484
expect(CURSOR_EVENT_MAP.sessionEnd).toBe("SessionEnd");
482485
expect(CURSOR_EVENT_MAP.stop).toBe("Stop");
486+
expect(CURSOR_EVENT_MAP.subagentStop).toBe("SubagentStop");
483487
});
484488

485489
it("CURSOR_EVENT_MAP keys exactly match CURSOR_HOOK_EVENT_TYPES", () => {

0 commit comments

Comments
 (0)