Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ Plus a **Constraints** block that forbids: touching files outside the list, rena

## Where things live

- `plugins/cursor/scripts/<cmd>.mjs` — command entrypoints (10; `adversarial-review` has no script of its own — it reuses `review.mjs --adversarial`). `session-hook.mjs` is the one non-command script: the SessionStart/SessionEnd hook entrypoint.
- `plugins/cursor/hooks/hooks.json` — Claude Code hook registration (session lifecycle).
- `plugins/cursor/scripts/lib/*.mjs` — shared helpers (run, id, args, paths, jobs, kill, parse, cursor, git, invoked, plan, hints, md).
- `plugins/cursor/scripts/<cmd>.mjs` — command entrypoints (10; `adversarial-review` has no script of its own — it reuses `review.mjs --adversarial`). Two non-command scripts: `session-hook.mjs` (SessionStart/SessionEnd) and `stop-review-gate-hook.mjs` (Stop).
- `plugins/cursor/hooks/hooks.json` — Claude Code hook registration (session lifecycle + optional stop review gate).
- `plugins/cursor/prompts/*.md` — prompt templates (`{{UPPER_SNAKE}}` placeholders, loaded via `lib/prompts.mjs`). Edit prompts here, not as JS string arrays.
- `plugins/cursor/schemas/review-output.schema.json` — the structured-review contract; validated hand-rolled in `lib/review-output.mjs` (zero-deps — do not add a schema-validator package).
- `plugins/cursor/scripts/lib/*.mjs` — shared helpers (run, id, args, paths, jobs, kill, parse, cursor, git, invoked, plan, hints, md, prompts, config, review-output).
- `plugins/cursor/commands/*.md` — slash command wrappers.
- `plugins/cursor/agents/cursor-runner.md` — the handoff subagent prompt.
- `plugins/cursor/skills/composer-prompting/` — Cursor prompt-shaping guidance the `cursor-runner` subagent references via its `skills:` frontmatter. `SKILL.md` is the always-loaded spine; the detailed material lives in `references/*.md` (prompt anatomy, model selection, anti-patterns), loaded on demand.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Stop review gate** (opt-in, per repo) — `/cursor:setup --enable-review-gate`. Before Claude Code ends a turn, the new `Stop` hook (`scripts/stop-review-gate-hook.mjs`) has a Cursor model review that turn's edits against `prompts/stop-review-gate.md`; the run must answer `ALLOW: …` or `BLOCK: …` on its first line, and a `BLOCK` keeps the turn going with the reason surfaced to Claude. Ported from `openai/codex-plugin-cc`'s stop-time review gate, with two hardening additions: `stop_hook_active` short-circuits so a block can never loop forever, and a missing/logged-out Cursor CLI degrades to a stderr note instead of blocking the session. Per-repo toggle persisted by the new `lib/config.mjs` (`<state-root>/config/<repo-hash>.json`).
- **Structured review output** — review prompts now ask the model to append a fenced ```json verdict block (contract: `schemas/review-output.schema.json` — verdict, summary, findings with severity/file/line, next steps). When it parses (hand-rolled validation in `lib/review-output.mjs`, zero-deps), the data is stored on the job record as `review` and the raw JSON fence is stripped from the human-facing summary; `/cursor:result --json` exposes it. Reviews from models that ignore the instruction stay unstructured — parsing never fails a review.
- **Prompt templates externalized** — the review prompt moved from a JS string array in `review.mjs` to `prompts/review.md` (`{{UPPER_SNAKE}}` placeholders, new `lib/prompts.mjs` loader/interpolator, blank-line collapse for empty optional blocks). Prompts are now reviewable as prose in PRs.
- **Session lifecycle hooks** (`hooks/hooks.json` + `scripts/session-hook.mjs`), modelled on `openai/codex-plugin-cc`. **SessionStart** exports the Claude session id (and `CLAUDE_PLUGIN_DATA`) into the session env via `CLAUDE_ENV_FILE`, so every job records which session started it (`sessionId` on the job record, stamped in `createJob`). **SessionEnd** cancels the session's still-running jobs — a closed Claude session no longer leaves detached workers and `cursor-agent` running unattended. Jobs from other sessions or without a session stamp are never touched.
- **`/cursor:status` scopes its default view to the current session.** The no-arg table now shows this session's jobs plus unattributed ones, with a hint line when rows are hidden; `--all` lifts both the session scope and the 10-row cap. New `lib/jobs.mjs#filterJobsForSession`.
- **`--json` on `/cursor:status`, `/cursor:result`, `/cursor:setup`.** Status and result emit the raw job record(s); setup emits the full doctor report (`checks[].ok`, `allOk`). Meant for scripting and future hooks that branch on job state instead of parsing Markdown.
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,13 +316,23 @@ Shortcut for `/cursor:delegate --resume <task...>`. Without a task, sends an emp

Shells out to `cursor-agent ls` and lists Cursor's own chat sessions for this repo. If that call times out or returns empty, the plugin falls back to its local job registry.

### `/cursor:setup [--doctor] [--print-models] [--install] [--json]`
### `/cursor:setup [--doctor] [--print-models] [--install] [--json] [--enable-review-gate|--disable-review-gate]`

Runs a quick health-check. `--doctor` produces extended diagnostics (Node version, PATH, `CURSOR_API_KEY` presence masked, jobs dir writability, cursor-agent version). `--print-models` shells out to `cursor-agent --list-models`. `--install` prints the install command but **does not run it** — you must copy-paste it yourself. `--json` emits the full doctor report as JSON (`checks[].ok`, `allOk`) for scripting.
Runs a quick health-check. `--doctor` produces extended diagnostics (Node version, PATH, `CURSOR_API_KEY` presence masked, jobs dir writability, cursor-agent version). `--print-models` shells out to `cursor-agent --list-models`. `--install` prints the install command but **does not run it** — you must copy-paste it yourself. `--json` emits the full doctor report as JSON (`checks[].ok`, `allOk`) for scripting. `--enable-review-gate` / `--disable-review-gate` toggle the per-repo [stop review gate](#stop-review-gate-opt-in).

## Stop review gate (opt-in)

```
/cursor:setup --enable-review-gate # per repo; --disable-review-gate turns it off
```

When enabled, a `Stop` hook has a Cursor model review each turn's edits **before Claude Code is allowed to end the turn**. The reviewer answers `ALLOW: …` or `BLOCK: …`; a block feeds the reason back to Claude, which keeps working until the issue is fixed. Turns without code changes are allowed through immediately, a missing or logged-out Cursor CLI degrades to a note instead of blocking, and the gate never fires twice in a row for the same stop (no loops). Off by default.

Review runs also emit a machine-readable verdict: the review prompt asks for a trailing JSON block (`schemas/review-output.schema.json` — verdict, findings with severity/file/line, next steps). When present, it is stored on the job record and available via `/cursor:result --json`; the human-facing summary stays pure Markdown.

## Session lifecycle

The plugin registers two Claude Code hooks (`hooks/hooks.json`):
The plugin registers Claude Code hooks (`hooks/hooks.json`):

- **SessionStart** exports the Claude session id into the session's environment, so every job created from that session is stamped with it. `/cursor:status` uses the stamp to scope its default view to *your* jobs — parallel Claude sessions in the same repo stop seeing each other's runs (use `--all` for everything).
- **SessionEnd** cancels the session's still-running jobs. Background delegates run as detached workers, so without this a closed Claude session would leave `cursor-agent` running unattended. Jobs from other sessions — or jobs with no session stamp — are left alone.
Expand Down
1 change: 1 addition & 0 deletions plugins/cursor/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ coverage
package-lock.json
*.ndjson
.cursor-plugin-cc
prompts/
6 changes: 4 additions & 2 deletions plugins/cursor/commands/setup.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
---
description: Health-check Cursor CLI, list models, or guide installation.
argument-hint: '[--doctor] [--print-models] [--install] [--json]'
description: Health-check Cursor CLI, list models, guide installation, or toggle the stop-time review gate.
argument-hint: '[--doctor] [--print-models] [--install] [--json] [--enable-review-gate|--disable-review-gate]'
allowed-tools: Bash(node:*)
---

!`node "${CLAUDE_PLUGIN_ROOT}/scripts/setup.mjs" -- "$ARGUMENTS"`

Present the check results as-is. If any check failed, tell the user concretely what to do (install cursor-agent, run `cursor-agent login`, run `npm install` inside the plugin). Never attempt to run the installer yourself.

`--enable-review-gate` / `--disable-review-gate` toggle the per-repo stop-time review gate: when enabled, a Cursor model reviews each turn's edits before Claude Code is allowed to stop, and a `BLOCK` verdict keeps the turn going. Relay the confirmation message verbatim.
13 changes: 12 additions & 1 deletion plugins/cursor/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
{
"description": "Session lifecycle for Cursor jobs: stamp jobs with the owning Claude session, cancel the session's running jobs on exit.",
"description": "Session lifecycle for Cursor jobs (stamp + cleanup) and the optional stop-time review gate.",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-review-gate-hook.mjs\"",
"timeout": 900
}
]
}
],
"SessionStart": [
{
"hooks": [
Expand Down
43 changes: 43 additions & 0 deletions plugins/cursor/prompts/review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{{ROLE}}

**Review target:** {{REVIEW_TARGET}}

{{FOCUS_BLOCK}}

Review ONLY the changes below — not the entire codebase.

{{BODY}}

---

**How to respond:**

- Group findings by severity: **Blocking**, **Should-fix**, **Nits**.
- For each finding: `path:line` — what is wrong, why it matters, and a concrete fix (described, not applied).
- Flag correctness bugs, security holes, missing error handling, broken or missing tests, and deviations from the repo conventions (read `AGENTS.md` / `.cursor/rules` / `CLAUDE.md` if present).
{{ADVERSARIAL_GUIDANCE}}
- End with a one-line verdict on its own line: **APPROVE**, **APPROVE WITH NITS**, or **REQUEST CHANGES**.
- After the verdict, append a fenced ```json code block that restates the review as machine-readable data (no new content), shaped exactly like this:

```json
{
"verdict": "approve | approve-with-nits | request-changes",
"summary": "one-sentence overall assessment",
"findings": [
{
"severity": "blocking | should-fix | nit",
"title": "short finding title",
"file": "path/relative/to/repo",
"line": 42,
"body": "what is wrong and why it matters",
"recommendation": "the concrete fix, described"
}
],
"next_steps": ["optional follow-up actions"]
}
```

**Hard constraints:**

- This is a **READ-ONLY review.** Do NOT modify, create, or delete any files. Do NOT run commands that change state. Do NOT stage or commit anything. If you spot a bug, describe the fix — never apply it.
- If the diff above was truncated, you MAY read specific files for context (read-only), but never edit them.
40 changes: 40 additions & 0 deletions plugins/cursor/prompts/stop-review-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<task>
Run a stop-gate review of the previous Claude turn.
Only review the work from the previous Claude turn.
Only review it if Claude actually made code changes in that turn.
Pure status, setup, or reporting output does not count as reviewable work.
For example, the output of /cursor:setup or /cursor:status does not count.
Only direct edits made in that specific turn count.
If the previous Claude turn was only a status update, a summary, a setup/login check, a review result, or output from a command that did not itself make direct edits in that turn, return ALLOW immediately and do no further work.
Challenge whether that specific work and its design choices should ship.

{{CLAUDE_RESPONSE_BLOCK}}
</task>

<compact_output_contract>
Return a compact final answer.
Your first line must be exactly one of:
- ALLOW: <short reason>
- BLOCK: <short reason>
Do not put anything before that first line.
</compact_output_contract>

<default_follow_through_policy>
Use ALLOW if the previous turn did not make code changes or if you do not see a blocking issue.
Use ALLOW immediately, without extra investigation, if the previous turn was not an edit-producing turn.
Use BLOCK only if the previous turn made code changes and you found something that still needs to be fixed before stopping.
</default_follow_through_policy>

<grounding_rules>
Ground every blocking claim in the repository context or tool outputs you inspected during this run.
Do not treat the previous Claude response as proof that code changes happened; verify that from the repository state before you block.
Do not block based on older edits from earlier turns when the immediately previous turn did not itself make direct edits.
</grounding_rules>

<dig_deeper_nudge>
If the previous turn did make code changes, check for second-order failures, empty-state behavior, retries, stale state, rollback risk, and design tradeoffs before you finalize.
</dig_deeper_nudge>

<hard_constraints>
This is a READ-ONLY check. Do NOT modify, create, or delete any files. Do NOT run commands that change state. Do NOT stage or commit anything.
</hard_constraints>
58 changes: 58 additions & 0 deletions plugins/cursor/schemas/review-output.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Cursor review structured output",
"description": "The fenced ```json block a /cursor:review run appends after its Markdown findings. Parsed by scripts/lib/review-output.mjs and stored on the job record as `review`.",
"type": "object",
"additionalProperties": false,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] The schema declares additionalProperties: false at both the top level and per-finding, but the hand-rolled validator in scripts/lib/review-output.mjs (isValidFinding, parseReviewOutput) never rejects objects with unexpected extra keys — it only checks that the required fields exist and have the right type. The schema and the actual runtime contract have quietly diverged.

Suggestion: Either drop additionalProperties: false from the schema (since it's documentation-only and not enforced) or add an explicit extra-keys check in the validator so the two stay in sync.

"required": ["verdict", "summary", "findings"],
"properties": {
"verdict": {
"type": "string",
"enum": ["approve", "approve-with-nits", "request-changes"]
},
"summary": {
"type": "string",
"minLength": 1
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["severity", "title", "file", "body"],
"properties": {
"severity": {
"type": "string",
"enum": ["blocking", "should-fix", "nit"]
},
"title": {
"type": "string",
"minLength": 1
},
"file": {
"type": "string",
"minLength": 1
},
"line": {
"type": ["integer", "null"],
"minimum": 1
},
"body": {
"type": "string",
"minLength": 1
},
"recommendation": {
"type": "string"
}
}
}
},
"next_steps": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
}
42 changes: 42 additions & 0 deletions plugins/cursor/scripts/lib/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Per-repo plugin config, persisted at `<state-root>/config/<repo-hash>.json`
// (deliberately outside jobs/<repo-hash>/ so config never mixes with job
// records). Currently the only key is the stop-review-gate toggle.

import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureDir, pluginHome, repoHash } from './paths.mjs';

const DEFAULTS = Object.freeze({ stopReviewGate: false });

/**
* @param {string} repoPath
* @returns {string}
*/
export function configPath(repoPath) {
return join(pluginHome(), 'config', `${repoHash(repoPath)}.json`);
}

/**
* @param {string} repoPath
* @returns {{stopReviewGate: boolean}}
*/
export function getConfig(repoPath) {
try {
return { ...DEFAULTS, ...JSON.parse(readFileSync(configPath(repoPath), 'utf8')) };
} catch {
return { ...DEFAULTS };
}
}

/**
* @param {string} repoPath
* @param {string} key
* @param {unknown} value
* @returns {{stopReviewGate: boolean}}
*/
export function setConfigValue(repoPath, key, value) {
ensureDir(join(pluginHome(), 'config'));
const next = { ...getConfig(repoPath), [key]: value };
writeFileSync(configPath(repoPath), JSON.stringify(next, null, 2) + '\n', 'utf8');
return next;
}
1 change: 1 addition & 0 deletions plugins/cursor/scripts/lib/jobs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const SESSION_ID_ENV = 'CURSOR_PLUGIN_CC_SESSION_ID';
* @property {boolean=} background
* @property {boolean=} cloud
* @property {string=} sessionId
* @property {import('./review-output.mjs').ReviewOutput=} review
*/

/**
Expand Down
31 changes: 31 additions & 0 deletions plugins/cursor/scripts/lib/prompts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Prompt templates live in `<plugin-root>/prompts/*.md` so they can be
// reviewed and diffed as prose instead of JS string arrays. Placeholders use
// `{{UPPER_SNAKE}}`; unknown keys interpolate to the empty string.

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

// lib/ → scripts/ → plugin root
const PLUGIN_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));

/**
* @param {string} name Template basename without extension (e.g. "review").
* @returns {string}
*/
export function loadPromptTemplate(name) {
return readFileSync(join(PLUGIN_ROOT, 'prompts', `${name}.md`), 'utf8');
}

/**
* @param {string} template
* @param {Record<string, string|undefined>} variables
* @returns {string}
*/
export function interpolateTemplate(template, variables) {
const filled = template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) =>
Object.prototype.hasOwnProperty.call(variables, key) ? String(variables[key] ?? '') : '',
);
// Optional blocks interpolate to '' and would leave runs of blank lines.
return filled.replace(/\n{3,}/g, '\n\n');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] interpolateTemplate's blank-line collapse (/\n{3,}/g\n\n) runs over the fully-assembled template, including the interpolated BODY (the raw diff/file content passed into the review prompt). Any diff hunk that legitimately contains 3+ consecutive blank lines gets silently collapsed to 2, so the text handed to the reviewer is not byte-for-byte the diff that was collected.

Suggestion: Collapse blank-line runs only around known optional placeholders (e.g. run the collapse before substituting BODY, or scope it to lines adjacent to a placeholder) rather than over the final interpolated string, so arbitrary diff content is never mutated.

}
Loading
Loading