From 1250a37b4d7f0ec7ae42f1912658e7bc2ba5a01a Mon Sep 17 00:00:00 2001 From: Tomas Grasl Date: Tue, 28 Jul 2026 15:30:34 +0200 Subject: [PATCH 1/2] feat(session): session lifecycle hooks, CLAUDE_PLUGIN_DATA state root, --json output Modelled on openai/codex-plugin-cc's session tracking, adapted to the zero-deps runtime: - hooks/hooks.json + scripts/session-hook.mjs: SessionStart exports the Claude session id (and CLAUDE_PLUGIN_DATA) into CLAUDE_ENV_FILE; SessionEnd cancels the session's still-running jobs so a closed session no longer leaves detached workers and cursor-agent running unattended. Other sessions' jobs and unattributed jobs are never touched. - createJob stamps sessionId from CURSOR_PLUGIN_CC_SESSION_ID - /cursor:status default view scoped to the current session (plus unattributed jobs) with a hidden-rows hint; --all lifts scope and cap; new lib/jobs.mjs#filterJobsForSession - --json on status/result/setup for scripting (raw job records / doctor report); setup's doctor split into gather + render - pluginHome(): CURSOR_PLUGIN_CC_HOME > existing ~/.cursor-plugin-cc > CLAUDE_PLUGIN_DATA/state > ~/.cursor-plugin-cc; jobs// layout unchanged - tests: session stamping, session filter, hook handlers (env-file write, selective cancel), state-root precedence - docs: README session-lifecycle section, AGENTS.md state-root rule, CHANGELOG Co-Authored-By: Claude Fable 5 --- AGENTS.md | 5 +- CHANGELOG.md | 12 +++ README.md | 24 +++-- plugins/cursor/commands/result.md | 2 +- plugins/cursor/commands/setup.md | 2 +- plugins/cursor/commands/status.md | 2 +- plugins/cursor/hooks/hooks.json | 27 ++++++ plugins/cursor/scripts/lib/jobs.mjs | 27 ++++++ plugins/cursor/scripts/lib/paths.mjs | 12 ++- plugins/cursor/scripts/result.mjs | 8 +- plugins/cursor/scripts/session-hook.mjs | 105 +++++++++++++++++++++ plugins/cursor/scripts/setup.mjs | 48 +++++++--- plugins/cursor/scripts/status.mjs | 27 ++++-- plugins/cursor/tests/helpers.mjs | 16 ++++ plugins/cursor/tests/paths.test.mjs | 47 ++++++++++ plugins/cursor/tests/session.test.mjs | 120 ++++++++++++++++++++++++ 16 files changed, 451 insertions(+), 33 deletions(-) create mode 100644 plugins/cursor/hooks/hooks.json create mode 100644 plugins/cursor/scripts/session-hook.mjs create mode 100644 plugins/cursor/tests/session.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 26b8e88..f8a840d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ A Claude Code plugin that delegates coding tasks from Claude to the Cursor CLI ( 2. **No build step.** No TypeScript, no bundler, no `dist/`. `scripts/*.mjs` IS the ship artefact. If you find yourself wanting one, something has gone wrong with the approach. 3. **Slash command scripts live under `plugins/cursor/scripts/.mjs`.** Their wrappers at `plugins/cursor/commands/.md` must use `node "${CLAUDE_PLUGIN_ROOT}/scripts/.mjs" -- "$ARGUMENTS"` with quoted `$ARGUMENTS` — unquoted breaks under zsh on any prompt containing `?`, `*`, or `@`. Exception: `review.md` and `adversarial-review.md` are model-orchestrated (they estimate the diff and ask wait-vs-background before running), so they give Claude the `node …` command in a fenced block rather than an auto-executing `!` line, and `adversarial-review` reuses `review.mjs --adversarial` instead of shipping its own script. 4. **`Bash(node:*)` is the only permission pattern used in `allowed-tools`.** Do not invent path-based patterns — Claude Code does not expand `${CLAUDE_PLUGIN_ROOT}` inside `allowed-tools`. Exception: the two estimate-first review commands additionally list `Bash(git:*)`, `AskUserQuestion`, and `Read, Glob, Grep` for the size-estimate/ask step — those are tool-name patterns, not path-based ones, so they are fine. -5. **Jobs are persisted under `~/.cursor-plugin-cc/jobs//`.** Never break that layout; users point scripts at those files when reporting bugs. +5. **Jobs are persisted under `/jobs//`.** Never break that layout; users point scripts at those files when reporting bugs. The state root resolves in `lib/paths.mjs#pluginHome`: `CURSOR_PLUGIN_CC_HOME` env → an existing `~/.cursor-plugin-cc` (legacy installs keep their history) → `CLAUDE_PLUGIN_DATA/state` (fresh installs) → `~/.cursor-plugin-cc`. 6. **Language: everything in this repo is English.** Code, comments, commit messages, docs, PR bodies, issue titles. The plugin does not impose a language policy on target repos — `cursor-runner` reads target-repo conventions — but this repo itself is English-only. 7. **Do not impose conventions on target repos.** The `cursor-runner` subagent reads `AGENTS.md` / `.cursor/rules` / existing code in whatever repo the user is working in and tells Cursor to match THAT style. When editing the subagent, do not hardcode English / Prettier / whatever. @@ -46,7 +46,8 @@ Plus a **Constraints** block that forbids: touching files outside the list, rena ## Where things live -- `plugins/cursor/scripts/.mjs` — command entrypoints (10; `adversarial-review` has no script of its own — it reuses `review.mjs --adversarial`). +- `plugins/cursor/scripts/.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, parse, cursor, git, invoked, plan, hints, md). - `plugins/cursor/commands/*.md` — slash command wrappers. - `plugins/cursor/agents/cursor-runner.md` — the handoff subagent prompt. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8430ce2..f119a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- **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. + +### Changed + +- **State root honours `CLAUDE_PLUGIN_DATA`.** Fresh installs store jobs under Claude Code's plugin data dir (`CLAUDE_PLUGIN_DATA/state/jobs//`), which is cleaned up with the plugin. Existing installs keep `~/.cursor-plugin-cc` — an existing legacy dir always wins so job history is never stranded. `CURSOR_PLUGIN_CC_HOME` still overrides everything. The `jobs//` layout is unchanged. + ## 0.4.0 — /cursor:adversarial-review + estimate-first reviews + composer-prompting skill Ported from upstream [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc) (whose `/codex:adversarial-review`, estimate-first review flow, and `gpt-5-4-prompting` skill this release mirrors), adapted to the Cursor CLI. diff --git a/README.md b/README.md index dda6498..bc53a70 100644 --- a/README.md +++ b/README.md @@ -275,18 +275,19 @@ Restart Cursor so `cursor-agent` picks up the new MCP. `--isolated` makes each r Verify with `/cursor:setup --doctor` — it now lists every MCP `cursor-agent` can see and whether each is loaded. -### `/cursor:status [job-id] [--all]` +### `/cursor:status [job-id] [--all] [--json]` -Without args, shows the last 10 jobs for this repository as a table. With an id, shows the full job record including the Cursor chat id (so you can resume manually with `cursor-agent --resume=`). Pass `--all` to drop the 10-row limit. +Without args, shows the jobs for this repository as a table — scoped to the current Claude Code session (plus jobs that predate session tracking), capped at 10 rows. With an id, shows the full job record including the Cursor chat id (so you can resume manually with `cursor-agent --resume=`). Pass `--all` to lift both the session scope and the row cap. Pass `--json` to get the raw job record(s) for scripting. ``` /cursor:status /cursor:status V1StGXR8_Z +/cursor:status --all ``` -### `/cursor:result [job-id]` +### `/cursor:result [job-id] [--json]` -Prints the final summary of a finished job. Defaults to the most recent one for this repo. +Prints the final summary of a finished job. Defaults to the most recent one for this repo. `--json` prints the raw job record (status, summary, filesTouched, exit code, chat id) instead of Markdown. ``` /cursor:result @@ -315,9 +316,18 @@ Shortcut for `/cursor:delegate --resume `. 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]` +### `/cursor:setup [--doctor] [--print-models] [--install] [--json]` -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. +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. + +## Session lifecycle + +The plugin registers two 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. + +If you *want* a run to outlive the session, start it outside the hook's reach (e.g. `node scripts/delegate.mjs` from a plain terminal) — jobs without a session stamp are never auto-cancelled. ## The two-phase loop @@ -417,7 +427,7 @@ The task file stays in `tasks/` as a durable record — the contract between pla | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `CURSOR_API_KEY` | Forwarded to `cursor-agent`. Optional — `cursor-agent login` is usually enough. | | `CURSOR_AGENT_BIN` | Override binary path (used by the test suite). | -| `CURSOR_PLUGIN_CC_HOME` | Override the jobs-registry root (default `~/.cursor-plugin-cc`). | +| `CURSOR_PLUGIN_CC_HOME` | Override the jobs-registry root. Default: an existing `~/.cursor-plugin-cc` if present, else Claude Code's plugin data dir (`CLAUDE_PLUGIN_DATA/state`), else `~/.cursor-plugin-cc`. | | `CURSOR_PLUGIN_CC_DEFAULT_MODEL` | Default `--model` when none is passed. Accepts the same aliases as `--model` (e.g. `composer`, `opus`). Falls back to `auto`. | A repo-local `.cursor-plugin-cc.json` is on the roadmap for overriding the default model per repo; until then, set `--model` per invocation or pin `CURSOR_PLUGIN_CC_DEFAULT_MODEL` in your shell. diff --git a/plugins/cursor/commands/result.md b/plugins/cursor/commands/result.md index 82f600d..e4474db 100644 --- a/plugins/cursor/commands/result.md +++ b/plugins/cursor/commands/result.md @@ -1,6 +1,6 @@ --- description: Print the final output of a finished Cursor job (most recent by default). -argument-hint: '[job-id]' +argument-hint: '[job-id] [--json]' allowed-tools: Bash(node:*) --- diff --git a/plugins/cursor/commands/setup.md b/plugins/cursor/commands/setup.md index 7304065..4bfe1d2 100644 --- a/plugins/cursor/commands/setup.md +++ b/plugins/cursor/commands/setup.md @@ -1,6 +1,6 @@ --- description: Health-check Cursor CLI, list models, or guide installation. -argument-hint: '[--doctor] [--print-models] [--install]' +argument-hint: '[--doctor] [--print-models] [--install] [--json]' allowed-tools: Bash(node:*) --- diff --git a/plugins/cursor/commands/status.md b/plugins/cursor/commands/status.md index 376f591..fe5bb74 100644 --- a/plugins/cursor/commands/status.md +++ b/plugins/cursor/commands/status.md @@ -1,6 +1,6 @@ --- description: Show active and recent Cursor jobs for this repository. -argument-hint: '[job-id] [--all]' +argument-hint: '[job-id] [--all] [--json]' disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/cursor/hooks/hooks.json b/plugins/cursor/hooks/hooks.json new file mode 100644 index 0000000..684d30a --- /dev/null +++ b/plugins/cursor/hooks/hooks.json @@ -0,0 +1,27 @@ +{ + "description": "Session lifecycle for Cursor jobs: stamp jobs with the owning Claude session, cancel the session's running jobs on exit.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-hook.mjs\" SessionStart", + "timeout": 5 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-hook.mjs\" SessionEnd", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/cursor/scripts/lib/jobs.mjs b/plugins/cursor/scripts/lib/jobs.mjs index 0a94d9b..77226fe 100644 --- a/plugins/cursor/scripts/lib/jobs.mjs +++ b/plugins/cursor/scripts/lib/jobs.mjs @@ -10,6 +10,12 @@ import { import { join } from 'node:path'; import { ensureDir, jobsDir, logsDir } from './paths.mjs'; +/** + * Set by the SessionStart hook (via CLAUDE_ENV_FILE), so every job created + * from a Claude Code session carries the id of the session that started it. + */ +export const SESSION_ID_ENV = 'CURSOR_PLUGIN_CC_SESSION_ID'; + /** * @typedef {'running'|'done'|'failed'|'cancelled'} JobStatus */ @@ -31,6 +37,7 @@ import { ensureDir, jobsDir, logsDir } from './paths.mjs'; * @property {string[]=} filesTouched * @property {boolean=} background * @property {boolean=} cloud + * @property {string=} sessionId */ /** @@ -41,6 +48,7 @@ import { ensureDir, jobsDir, logsDir } from './paths.mjs'; * @property {string} model * @property {boolean=} background * @property {boolean=} cloud + * @property {string=} sessionId */ /** @@ -82,6 +90,9 @@ function atomicWrite(target, data) { export function createJob(init) { ensureDir(jobsDir(init.repoPath)); ensureDir(logsDir(init.repoPath)); + // Stamp the owning Claude session so /cursor:status can scope its default + // view and the SessionEnd hook knows which running jobs belong to it. + const sessionId = init.sessionId ?? process.env[SESSION_ID_ENV]; /** @type {JobRecord} */ const record = { id: init.id, @@ -93,6 +104,7 @@ export function createJob(init) { rawLogPath: rawLogPath(init.repoPath, init.id), ...(init.background ? { background: true } : {}), ...(init.cloud ? { cloud: true } : {}), + ...(sessionId && sessionId.trim() ? { sessionId: sessionId.trim() } : {}), }; atomicWrite(jobFilePath(init.repoPath, init.id), JSON.stringify(record, null, 2)); return record; @@ -252,6 +264,21 @@ export async function cancelJob(repoPath, id, graceMs = 5_000) { }); } +/** + * Jobs a given Claude session should see by default: its own, plus records + * with no session stamp (pre-hook jobs, or runs outside Claude Code) — those + * cannot be attributed, so hiding them would make them undiscoverable. + * Without a session id, everything is visible. + * + * @param {JobRecord[]} jobs + * @param {string|undefined} sessionId + * @returns {JobRecord[]} + */ +export function filterJobsForSession(jobs, sessionId) { + if (!sessionId) return jobs; + return jobs.filter((j) => !j.sessionId || j.sessionId === sessionId); +} + /** * @param {string} repoPath * @returns {JobRecord[]} diff --git a/plugins/cursor/scripts/lib/paths.mjs b/plugins/cursor/scripts/lib/paths.mjs index d404280..2b68bb0 100644 --- a/plugins/cursor/scripts/lib/paths.mjs +++ b/plugins/cursor/scripts/lib/paths.mjs @@ -1,12 +1,20 @@ import { createHash } from 'node:crypto'; -import { mkdirSync, realpathSync } from 'node:fs'; +import { existsSync, mkdirSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; export function pluginHome() { const fromEnv = process.env.CURSOR_PLUGIN_CC_HOME; if (fromEnv && fromEnv.trim().length > 0) return resolve(fromEnv); - return join(homedir(), '.cursor-plugin-cc'); + // Existing installs keep their state where it already lives — the harness + // starting to provide a data dir must never strand previous job history. + const legacy = join(homedir(), '.cursor-plugin-cc'); + if (existsSync(legacy)) return legacy; + // Fresh installs prefer the Claude-Code-managed plugin data dir: it is + // cleaned up with the plugin instead of leaving state behind in $HOME. + const pluginData = process.env.CLAUDE_PLUGIN_DATA; + if (pluginData && pluginData.trim().length > 0) return join(resolve(pluginData), 'state'); + return legacy; } /** diff --git a/plugins/cursor/scripts/result.mjs b/plugins/cursor/scripts/result.mjs index 675f20a..3da2f62 100644 --- a/plugins/cursor/scripts/result.mjs +++ b/plugins/cursor/scripts/result.mjs @@ -37,7 +37,7 @@ function render(job) { * @returns {Promise} */ export async function main(rawArgv) { - const { positional } = parseCommandArgv(rawArgv); + const { positional, flags } = parseCommandArgv(rawArgv, ['json']); const root = await repoRoot(process.cwd()); const id = positional[0]; const job = id ? readJob(root, id) : mostRecentFinishedJob(root); @@ -47,6 +47,12 @@ export async function main(rawArgv) { ); return 1; } + if (flags['json']) { + // The record carries status, summary, filesTouched, exitCode, chat id — + // callers (hooks, scripts) branch on those instead of parsing Markdown. + process.stdout.write(JSON.stringify(job, null, 2) + '\n'); + return 0; + } if (job.status === 'running') { process.stdout.write( `Job \`${job.id}\` is still running. Use \`/cursor:status ${job.id}\` to monitor it.\n`, diff --git a/plugins/cursor/scripts/session-hook.mjs b/plugins/cursor/scripts/session-hook.mjs new file mode 100644 index 0000000..5ffd7ae --- /dev/null +++ b/plugins/cursor/scripts/session-hook.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// Claude Code session lifecycle hook (SessionStart / SessionEnd). +// +// SessionStart: exports the session id (and CLAUDE_PLUGIN_DATA, which slash +// command invocations do not receive automatically) into CLAUDE_ENV_FILE so +// every subsequent script run in the session can stamp jobs with the owning +// session and resolve the harness-managed state dir. +// +// SessionEnd: cancels THIS session's still-running jobs. Background workers +// are detached, so without this a closed Claude session leaves cursor-agent +// running unattended. Jobs from other sessions — or with no session stamp — +// are deliberately left alone. + +import { appendFileSync, readFileSync } from 'node:fs'; +import { repoRoot } from './lib/git.mjs'; +import { SESSION_ID_ENV, cancelJob, listJobs } from './lib/jobs.mjs'; +import { invokedAsScript as __isScript } from './lib/invoked.mjs'; + +const PLUGIN_DATA_ENV = 'CLAUDE_PLUGIN_DATA'; + +/** @param {string} value */ +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + +/** + * @param {string} name + * @param {string|undefined} value + */ +function appendEnvVar(name, value) { + const envFile = process.env.CLAUDE_ENV_FILE; + if (!envFile || !value) return; + appendFileSync(envFile, `export ${name}=${shellQuote(value)}\n`, 'utf8'); +} + +/** @returns {Record} */ +function readHookInput() { + try { + const raw = readFileSync(0, 'utf8').trim(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +/** + * @param {Record} input + */ +export function handleSessionStart(input) { + const sessionId = typeof input.session_id === 'string' ? input.session_id : undefined; + appendEnvVar(SESSION_ID_ENV, sessionId); + appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]); +} + +/** + * @param {Record} input + * @returns {Promise} number of jobs cancelled + */ +export async function handleSessionEnd(input) { + const sessionId = + (typeof input.session_id === 'string' && input.session_id) || process.env[SESSION_ID_ENV]; + if (!sessionId) return 0; + const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd(); + const root = await repoRoot(cwd); + const mine = listJobs(root).filter((j) => j.status === 'running' && j.sessionId === sessionId); + await Promise.all(mine.map((j) => cancelJob(root, j.id, 3_000))); + return mine.length; +} + +/** + * @param {string[]} rawArgv + * @returns {Promise} + */ +export async function main(rawArgv) { + const input = readHookInput(); + const eventName = rawArgv[0] ?? input.hook_event_name ?? ''; + if (eventName === 'SessionStart') { + handleSessionStart(input); + return 0; + } + if (eventName === 'SessionEnd') { + const cancelled = await handleSessionEnd(input); + if (cancelled > 0) { + process.stderr.write( + `cursor-plugin-cc: cancelled ${cancelled} running job(s) on session end.\n`, + ); + } + return 0; + } + return 0; +} + +const invokedAsScript = __isScript(import.meta.url); + +if (invokedAsScript) { + main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write( + `session-hook failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + // Never block the session over hook housekeeping. + process.exit(0); + }); +} diff --git a/plugins/cursor/scripts/setup.mjs b/plugins/cursor/scripts/setup.mjs index 1881813..128f883 100644 --- a/plugins/cursor/scripts/setup.mjs +++ b/plugins/cursor/scripts/setup.mjs @@ -41,12 +41,9 @@ function maskKey(value) { return `${value.slice(0, 4)}…${value.slice(-4)}`; } -async function doctor() { - const lines = ['### /cursor:setup --doctor\n']; +async function gatherDoctor() { + /** @type {Array<[string, {ok: boolean, detail: string}]>} */ const checks = []; - lines.push(`- Node: ${process.version}`); - lines.push(`- Platform: ${process.platform} (${process.arch})`); - lines.push(`- Plugin home: \`${pluginHome()}\``); let bin = ''; try { @@ -83,6 +80,37 @@ async function doctor() { { ok: true, detail: apiKey ? `set (${maskKey(apiKey)})` : 'not set (using local session)' }, ]); + const mcps = bin ? await listConfiguredMcps() : []; + + // The CURSOR_API_KEY check is already `ok:true` whether or not the key is + // set, so a literal `r.ok` is correct here — a stray "not set" substring in + // some other check's stderr must not mask a real failure. + const allOk = checks.every(([, r]) => r.ok); + return { bin, checks, mcps, allOk }; +} + +async function doctor(asJson = false) { + const { bin, checks, mcps, allOk } = await gatherDoctor(); + + if (asJson) { + const payload = { + node: process.version, + platform: process.platform, + arch: process.arch, + pluginHome: pluginHome(), + checks: checks.map(([name, r]) => ({ name, ok: r.ok, detail: r.detail })), + mcps, + allOk, + }; + process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); + return allOk ? 0 : 1; + } + + const lines = ['### /cursor:setup --doctor\n']; + lines.push(`- Node: ${process.version}`); + lines.push(`- Platform: ${process.platform} (${process.arch})`); + lines.push(`- Plugin home: \`${pluginHome()}\``); + lines.push(''); for (const [name, r] of checks) { const icon = r.ok ? '✓' : '✗'; @@ -90,7 +118,6 @@ async function doctor() { } if (bin) { - const mcps = await listConfiguredMcps(); lines.push(''); lines.push('**Configured Cursor MCPs:**'); if (mcps.length === 0) { @@ -103,10 +130,6 @@ async function doctor() { } } - // The CURSOR_API_KEY check is already `ok:true` whether or not the key is - // set, so a literal `r.ok` is correct here — a stray "not set" substring in - // some other check's stderr must not mask a real failure. - const allOk = checks.every(([, r]) => r.ok); lines.push(''); lines.push(allOk ? 'All checks passed.' : 'Some checks failed — see above.'); process.stdout.write(lines.join('\n') + '\n'); @@ -170,7 +193,10 @@ async function baseCheck() { * @returns {Promise} */ export async function main(rawArgv) { - const { flags } = parseCommandArgv(rawArgv, ['doctor', 'print-models', 'install']); + const { flags } = parseCommandArgv(rawArgv, ['doctor', 'print-models', 'install', 'json']); + // --json always emits the full structured doctor report — hooks and scripts + // branch on `checks[].ok` / `allOk` instead of parsing Markdown. + if (flags['json']) return doctor(true); if (flags['doctor']) return doctor(); if (flags['print-models'] || flags['printModels']) return printModels(); if (flags['install']) return maybeInstall(); diff --git a/plugins/cursor/scripts/status.mjs b/plugins/cursor/scripts/status.mjs index 87ad103..b0289bb 100644 --- a/plugins/cursor/scripts/status.mjs +++ b/plugins/cursor/scripts/status.mjs @@ -2,7 +2,7 @@ import { parseCommandArgv } from './lib/args.mjs'; import { repoRoot } from './lib/git.mjs'; import { jobNotFoundMessage } from './lib/hints.mjs'; -import { listJobs, readJob } from './lib/jobs.mjs'; +import { SESSION_ID_ENV, filterJobsForSession, listJobs, readJob } from './lib/jobs.mjs'; import { mdCell } from './lib/md.mjs'; function age(iso) { @@ -77,7 +77,8 @@ function renderDetail(r) { * @returns {Promise} */ export async function main(rawArgv) { - const { positional, flags } = parseCommandArgv(rawArgv, ['all']); + const { positional, flags } = parseCommandArgv(rawArgv, ['all', 'json']); + const asJson = Boolean(flags['json']); const root = await repoRoot(process.cwd()); const id = positional[0]; if (id) { @@ -86,14 +87,26 @@ export async function main(rawArgv) { process.stderr.write(jobNotFoundMessage(id)); return 1; } - process.stdout.write(renderDetail(job)); + process.stdout.write(asJson ? JSON.stringify(job, null, 2) + '\n' : renderDetail(job)); + return 0; + } + // Default view is scoped to the current Claude session (plus unattributed + // jobs); --all lifts both the session scope and the 10-row cap. + const all = listJobs(root); + const rows = flags['all'] + ? all + : filterJobsForSession(all, process.env[SESSION_ID_ENV]).slice(0, 10); + if (asJson) { + process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; } - const limit = flags['all'] ? undefined : 10; - const listOpts = {}; - if (typeof limit === 'number') listOpts.limit = limit; - const rows = listJobs(root, listOpts); process.stdout.write(renderTable(rows)); + const hidden = all.length - rows.length; + if (hidden > 0) { + process.stdout.write( + `\n_${hidden} job(s) hidden (other sessions or older) — use \`--all\` to list everything._\n`, + ); + } return 0; } diff --git a/plugins/cursor/tests/helpers.mjs b/plugins/cursor/tests/helpers.mjs index 82cc2b4..62bef70 100644 --- a/plugins/cursor/tests/helpers.mjs +++ b/plugins/cursor/tests/helpers.mjs @@ -10,6 +10,22 @@ export function makeTempHome() { }; } +export function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export async function waitForDeath(pid, timeoutMs = 3_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && isAlive(pid)) { + await new Promise((r) => setTimeout(r, 50)); + } +} + export const STUB_BIN = new URL('./fixtures/cursor-agent-stub.mjs', import.meta.url).pathname; export const HAPPY_FIXTURE = new URL('./fixtures/cursor-events/happy-path.ndjson', import.meta.url) .pathname; diff --git a/plugins/cursor/tests/paths.test.mjs b/plugins/cursor/tests/paths.test.mjs index 3d4dca2..be017dc 100644 --- a/plugins/cursor/tests/paths.test.mjs +++ b/plugins/cursor/tests/paths.test.mjs @@ -1,3 +1,5 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { pluginHome, repoHash, jobsDir } from '../scripts/lib/paths.mjs'; import { makeTempHome } from './helpers.mjs'; @@ -33,4 +35,49 @@ describe('paths', () => { expect(dir.startsWith(tmp.dir)).toBe(true); expect(dir).toContain('jobs'); }); + + describe('state-root resolution without the env override', () => { + // os.homedir() reads $HOME on POSIX, so point it at a temp dir to control + // whether the legacy ~/.cursor-plugin-cc exists. + let fakeHome; + const prevHOME = process.env.HOME; + const prevPluginData = process.env.CLAUDE_PLUGIN_DATA; + + beforeEach(() => { + fakeHome = makeTempHome(); + process.env.HOME = fakeHome.dir; + delete process.env.CURSOR_PLUGIN_CC_HOME; + delete process.env.CLAUDE_PLUGIN_DATA; + }); + + afterEach(() => { + if (prevHOME === undefined) delete process.env.HOME; + else process.env.HOME = prevHOME; + if (prevPluginData === undefined) delete process.env.CLAUDE_PLUGIN_DATA; + else process.env.CLAUDE_PLUGIN_DATA = prevPluginData; + fakeHome.cleanup(); + }); + + it.skipIf(process.platform === 'win32')('defaults to ~/.cursor-plugin-cc', () => { + expect(pluginHome()).toBe(join(fakeHome.dir, '.cursor-plugin-cc')); + }); + + it.skipIf(process.platform === 'win32')( + 'prefers CLAUDE_PLUGIN_DATA/state on a fresh install', + () => { + process.env.CLAUDE_PLUGIN_DATA = join(fakeHome.dir, 'plugin-data'); + expect(pluginHome()).toBe(join(fakeHome.dir, 'plugin-data', 'state')); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'keeps an existing legacy dir even when CLAUDE_PLUGIN_DATA is set', + () => { + const legacy = join(fakeHome.dir, '.cursor-plugin-cc'); + mkdirSync(legacy, { recursive: true }); + process.env.CLAUDE_PLUGIN_DATA = join(fakeHome.dir, 'plugin-data'); + expect(pluginHome()).toBe(legacy); + }, + ); + }); }); diff --git a/plugins/cursor/tests/session.test.mjs b/plugins/cursor/tests/session.test.mjs new file mode 100644 index 0000000..1f7a89d --- /dev/null +++ b/plugins/cursor/tests/session.test.mjs @@ -0,0 +1,120 @@ +import { spawn } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + SESSION_ID_ENV, + createJob, + filterJobsForSession, + listJobs, + readJob, + updateJob, +} from '../scripts/lib/jobs.mjs'; +import { handleSessionEnd, handleSessionStart } from '../scripts/session-hook.mjs'; +import { isAlive, makeTempHome, waitForDeath } from './helpers.mjs'; + +describe('session lifecycle', () => { + let tmp; + const prevHome = process.env.CURSOR_PLUGIN_CC_HOME; + const prevSession = process.env[SESSION_ID_ENV]; + const prevEnvFile = process.env.CLAUDE_ENV_FILE; + const repo = '/tmp/some-repo-path'; + + beforeEach(() => { + tmp = makeTempHome(); + process.env.CURSOR_PLUGIN_CC_HOME = tmp.dir; + delete process.env[SESSION_ID_ENV]; + delete process.env.CLAUDE_ENV_FILE; + }); + + afterEach(() => { + if (prevHome === undefined) delete process.env.CURSOR_PLUGIN_CC_HOME; + else process.env.CURSOR_PLUGIN_CC_HOME = prevHome; + if (prevSession === undefined) delete process.env[SESSION_ID_ENV]; + else process.env[SESSION_ID_ENV] = prevSession; + if (prevEnvFile === undefined) delete process.env.CLAUDE_ENV_FILE; + else process.env.CLAUDE_ENV_FILE = prevEnvFile; + tmp.cleanup(); + }); + + it('createJob stamps the session id from the env', () => { + process.env[SESSION_ID_ENV] = 'sess-abc'; + const job = createJob({ id: 'j1', repoPath: repo, prompt: 'p', model: 'm' }); + expect(job.sessionId).toBe('sess-abc'); + expect(readJob(repo, 'j1')?.sessionId).toBe('sess-abc'); + }); + + it('createJob leaves sessionId unset without the env', () => { + const job = createJob({ id: 'j2', repoPath: repo, prompt: 'p', model: 'm' }); + expect(job.sessionId).toBeUndefined(); + }); + + it('filterJobsForSession keeps own and unattributed jobs', () => { + const jobs = [ + { id: 'mine', sessionId: 's1' }, + { id: 'theirs', sessionId: 's2' }, + { id: 'legacy' }, + ]; + expect(filterJobsForSession(jobs, 's1').map((j) => j.id)).toEqual(['mine', 'legacy']); + expect(filterJobsForSession(jobs, undefined).map((j) => j.id)).toEqual([ + 'mine', + 'theirs', + 'legacy', + ]); + }); + + it('SessionStart exports the session id into CLAUDE_ENV_FILE', () => { + const envFile = join(tmp.dir, 'env.sh'); + writeFileSync(envFile, '', 'utf8'); + process.env.CLAUDE_ENV_FILE = envFile; + handleSessionStart({ session_id: "se'ss-1" }); + const content = readFileSync(envFile, 'utf8'); + // Quoting must survive an embedded single quote. + expect(content).toContain(`export ${SESSION_ID_ENV}='se'"'"'ss-1'`); + }); + + it('SessionStart without CLAUDE_ENV_FILE is a no-op', () => { + expect(() => handleSessionStart({ session_id: 's1' })).not.toThrow(); + }); + + it.skipIf(process.platform === 'win32')( + 'SessionEnd cancels only this session running jobs', + async () => { + const mine = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + const other = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + try { + createJob({ id: 'mine', repoPath: repo, prompt: 'p', model: 'm', sessionId: 's1' }); + updateJob(repo, 'mine', { pid: mine.pid }); + createJob({ id: 'other', repoPath: repo, prompt: 'p', model: 'm', sessionId: 's2' }); + updateJob(repo, 'other', { pid: other.pid }); + createJob({ id: 'finished', repoPath: repo, prompt: 'p', model: 'm', sessionId: 's1' }); + updateJob(repo, 'finished', { status: 'done' }); + + // repo is not a git repo, so repoRoot(cwd) falls back to cwd — pass + // the jobs key path directly. + const cancelled = await handleSessionEnd({ session_id: 's1', cwd: repo }); + expect(cancelled).toBe(1); + expect(readJob(repo, 'mine')?.status).toBe('cancelled'); + expect(readJob(repo, 'other')?.status).toBe('running'); + expect(readJob(repo, 'finished')?.status).toBe('done'); + await waitForDeath(mine.pid); + expect(isAlive(mine.pid)).toBe(false); + expect(isAlive(other.pid)).toBe(true); + expect(listJobs(repo).length).toBe(3); + } finally { + for (const child of [mine, other]) { + if (!child.killed) child.kill('SIGKILL'); + } + } + }, + ); + + it('SessionEnd without a session id does nothing', async () => { + const cancelled = await handleSessionEnd({ cwd: repo }); + expect(cancelled).toBe(0); + }); +}); From 73ad1804ce06f39dfe84216f2c77e349d6216f48 Mon Sep 17 00:00:00 2001 From: Tomas Grasl Date: Tue, 28 Jul 2026 15:39:45 +0200 Subject: [PATCH 2/2] feat(review): prompt templates, structured review output, opt-in stop review gate Ported from openai/codex-plugin-cc's review chain, adapted to the zero-deps runtime: - prompts/*.md: review prompt moved out of JS string arrays into reviewable templates ({{UPPER_SNAKE}} placeholders, new lib/prompts.mjs loader/interpolator with blank-line collapse); prompts/ excluded from prettier so template whitespace stays intentional - schemas/review-output.schema.json + lib/review-output.mjs: reviews append a fenced json verdict block (verdict/summary/findings/next_steps, hand-rolled validation); parsed data stored on the job record as `review`, raw fence stripped from the human-facing summary; parsing never fails a review - stop review gate (opt-in, per repo): Stop hook runs a Cursor review of the turn with an ALLOW:/BLOCK: first-line contract; BLOCK surfaces {"decision":"block"} so the turn continues. stop_hook_active short-circuits (no loops); missing/logged-out Cursor degrades to a stderr note; also warns about still-running jobs on stop - lib/config.mjs: per-repo config at /config/.json; /cursor:setup --enable-review-gate / --disable-review-gate toggle, gate status shown in doctor/--json - test stub understands `cursor-agent status` (CURSOR_AGENT_STUB_AUTH) - tests: prompts, config, review-output parser, gate hook (allow/block/loop-guard/logged-out) --- AGENTS.md | 8 +- CHANGELOG.md | 4 + README.md | 16 +- plugins/cursor/.prettierignore | 1 + plugins/cursor/commands/setup.md | 6 +- plugins/cursor/hooks/hooks.json | 13 +- plugins/cursor/prompts/review.md | 43 +++++ plugins/cursor/prompts/stop-review-gate.md | 40 ++++ .../cursor/schemas/review-output.schema.json | 58 ++++++ plugins/cursor/scripts/lib/config.mjs | 42 +++++ plugins/cursor/scripts/lib/jobs.mjs | 1 + plugins/cursor/scripts/lib/prompts.mjs | 31 +++ plugins/cursor/scripts/lib/review-output.mjs | 104 +++++++++++ plugins/cursor/scripts/review.mjs | 57 +++--- plugins/cursor/scripts/setup.mjs | 44 ++++- .../cursor/scripts/stop-review-gate-hook.mjs | 176 ++++++++++++++++++ plugins/cursor/tests/config.test.mjs | 38 ++++ .../tests/fixtures/cursor-agent-stub.mjs | 11 ++ .../fixtures/cursor-events/gate-allow.ndjson | 2 + .../fixtures/cursor-events/gate-block.ndjson | 2 + plugins/cursor/tests/gate.test.mjs | 99 ++++++++++ plugins/cursor/tests/prompts.test.mjs | 24 +++ plugins/cursor/tests/review-output.test.mjs | 86 +++++++++ 23 files changed, 862 insertions(+), 44 deletions(-) create mode 100644 plugins/cursor/prompts/review.md create mode 100644 plugins/cursor/prompts/stop-review-gate.md create mode 100644 plugins/cursor/schemas/review-output.schema.json create mode 100644 plugins/cursor/scripts/lib/config.mjs create mode 100644 plugins/cursor/scripts/lib/prompts.mjs create mode 100644 plugins/cursor/scripts/lib/review-output.mjs create mode 100644 plugins/cursor/scripts/stop-review-gate-hook.mjs create mode 100644 plugins/cursor/tests/config.test.mjs create mode 100644 plugins/cursor/tests/fixtures/cursor-events/gate-allow.ndjson create mode 100644 plugins/cursor/tests/fixtures/cursor-events/gate-block.ndjson create mode 100644 plugins/cursor/tests/gate.test.mjs create mode 100644 plugins/cursor/tests/prompts.test.mjs create mode 100644 plugins/cursor/tests/review-output.test.mjs diff --git a/AGENTS.md b/AGENTS.md index f8a840d..6e1bb96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,9 +46,11 @@ Plus a **Constraints** block that forbids: touching files outside the list, rena ## Where things live -- `plugins/cursor/scripts/.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, parse, cursor, git, invoked, plan, hints, md). +- `plugins/cursor/scripts/.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, 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/SKILL.md` — Cursor prompt-shaping guidance the `cursor-runner` subagent references via its `skills:` frontmatter. diff --git a/CHANGELOG.md b/CHANGELOG.md index f119a22..9bd78b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 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` (`/config/.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. diff --git a/README.md b/README.md index bc53a70..f4c9cc0 100644 --- a/README.md +++ b/README.md @@ -316,13 +316,23 @@ Shortcut for `/cursor:delegate --resume `. 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. diff --git a/plugins/cursor/.prettierignore b/plugins/cursor/.prettierignore index ff9d47c..bd02fe9 100644 --- a/plugins/cursor/.prettierignore +++ b/plugins/cursor/.prettierignore @@ -4,3 +4,4 @@ coverage package-lock.json *.ndjson .cursor-plugin-cc +prompts/ diff --git a/plugins/cursor/commands/setup.md b/plugins/cursor/commands/setup.md index 4bfe1d2..e448f1f 100644 --- a/plugins/cursor/commands/setup.md +++ b/plugins/cursor/commands/setup.md @@ -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. diff --git a/plugins/cursor/hooks/hooks.json b/plugins/cursor/hooks/hooks.json index 684d30a..a92e014 100644 --- a/plugins/cursor/hooks/hooks.json +++ b/plugins/cursor/hooks/hooks.json @@ -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": [ diff --git a/plugins/cursor/prompts/review.md b/plugins/cursor/prompts/review.md new file mode 100644 index 0000000..6da3a57 --- /dev/null +++ b/plugins/cursor/prompts/review.md @@ -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. diff --git a/plugins/cursor/prompts/stop-review-gate.md b/plugins/cursor/prompts/stop-review-gate.md new file mode 100644 index 0000000..548e154 --- /dev/null +++ b/plugins/cursor/prompts/stop-review-gate.md @@ -0,0 +1,40 @@ + +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}} + + + +Return a compact final answer. +Your first line must be exactly one of: +- ALLOW: +- BLOCK: +Do not put anything before that first line. + + + +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. + + + +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. + + + +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. + + + +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. + diff --git a/plugins/cursor/schemas/review-output.schema.json b/plugins/cursor/schemas/review-output.schema.json new file mode 100644 index 0000000..c38b20d --- /dev/null +++ b/plugins/cursor/schemas/review-output.schema.json @@ -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, + "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 + } + } + } +} diff --git a/plugins/cursor/scripts/lib/config.mjs b/plugins/cursor/scripts/lib/config.mjs new file mode 100644 index 0000000..ba0f5cb --- /dev/null +++ b/plugins/cursor/scripts/lib/config.mjs @@ -0,0 +1,42 @@ +// Per-repo plugin config, persisted at `/config/.json` +// (deliberately outside jobs// 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; +} diff --git a/plugins/cursor/scripts/lib/jobs.mjs b/plugins/cursor/scripts/lib/jobs.mjs index 77226fe..402d7e7 100644 --- a/plugins/cursor/scripts/lib/jobs.mjs +++ b/plugins/cursor/scripts/lib/jobs.mjs @@ -38,6 +38,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 */ /** diff --git a/plugins/cursor/scripts/lib/prompts.mjs b/plugins/cursor/scripts/lib/prompts.mjs new file mode 100644 index 0000000..1a6969a --- /dev/null +++ b/plugins/cursor/scripts/lib/prompts.mjs @@ -0,0 +1,31 @@ +// Prompt templates live in `/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} 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'); +} diff --git a/plugins/cursor/scripts/lib/review-output.mjs b/plugins/cursor/scripts/lib/review-output.mjs new file mode 100644 index 0000000..e87359a --- /dev/null +++ b/plugins/cursor/scripts/lib/review-output.mjs @@ -0,0 +1,104 @@ +// Parse the structured ```json block a review run appends after its Markdown +// findings (contract: schemas/review-output.schema.json). Hand-rolled +// structural validation — no schema-validator dependency (zero-deps rule). +// Reviews from models that ignore the instruction simply stay unstructured; +// nothing here is allowed to fail a review. + +export const VERDICTS = ['approve', 'approve-with-nits', 'request-changes']; +export const SEVERITIES = ['blocking', 'should-fix', 'nit']; + +const FENCE_RE = /```json\s*\n([\s\S]*?)```/g; + +/** + * Last fenced ```json block in the text (the verdict block is instructed to + * come last; earlier blocks may be quoted diff content). + * + * @param {string} text + * @returns {string|null} + */ +export function extractJsonBlock(text) { + let last = null; + for (const m of String(text ?? '').matchAll(FENCE_RE)) last = m[1]; + return last; +} + +/** + * Strip the trailing structured block from a review summary for display — + * the data lives on the job record; the JSON fence is noise for a human. + * + * @param {string} text + * @returns {string} + */ +export function stripJsonBlock(text) { + const s = String(text ?? ''); + const matches = [...s.matchAll(FENCE_RE)]; + const last = matches[matches.length - 1]; + if (!last) return s; + return (s.slice(0, last.index) + s.slice(last.index + last[0].length)).trim(); +} + +/** + * @param {unknown} f + * @returns {boolean} + */ +function isValidFinding(f) { + if (!f || typeof f !== 'object' || Array.isArray(f)) return false; + const o = /** @type {Record} */ (f); + if (!SEVERITIES.includes(String(o.severity))) return false; + if (typeof o.title !== 'string' || !o.title.trim()) return false; + if (typeof o.file !== 'string' || !o.file.trim()) return false; + if (typeof o.body !== 'string' || !o.body.trim()) return false; + if (o.line != null && (!Number.isInteger(o.line) || Number(o.line) < 1)) return false; + if (o.recommendation != null && typeof o.recommendation !== 'string') return false; + return true; +} + +/** + * @typedef {Object} ReviewOutput + * @property {'approve'|'approve-with-nits'|'request-changes'} verdict + * @property {string} summary + * @property {Array<{severity: string, title: string, file: string, line?: number|null, body: string, recommendation?: string}>} findings + * @property {string[]} next_steps + */ + +/** + * @param {string} text Full review summary text. + * @returns {{ok: true, data: ReviewOutput} | {ok: false, error: string}} + */ +export function parseReviewOutput(text) { + const block = extractJsonBlock(text); + if (!block) return { ok: false, error: 'no fenced json block found' }; + let parsed; + try { + parsed = JSON.parse(block); + } catch (err) { + return { + ok: false, + error: `invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + }; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, error: 'top level is not an object' }; + } + if (!VERDICTS.includes(String(parsed.verdict))) { + return { ok: false, error: `unknown verdict: ${String(parsed.verdict)}` }; + } + if (typeof parsed.summary !== 'string' || !parsed.summary.trim()) { + return { ok: false, error: 'missing summary' }; + } + if (!Array.isArray(parsed.findings) || !parsed.findings.every(isValidFinding)) { + return { ok: false, error: 'findings missing or malformed' }; + } + const nextSteps = Array.isArray(parsed.next_steps) + ? parsed.next_steps.filter((s) => typeof s === 'string' && s.trim()) + : []; + return { + ok: true, + data: { + verdict: parsed.verdict, + summary: parsed.summary, + findings: parsed.findings, + next_steps: nextSteps, + }, + }; +} diff --git a/plugins/cursor/scripts/review.mjs b/plugins/cursor/scripts/review.mjs index b850f8f..97cf63d 100644 --- a/plugins/cursor/scripts/review.mjs +++ b/plugins/cursor/scripts/review.mjs @@ -14,6 +14,8 @@ import { } from './lib/jobs.mjs'; import { ensureDir, jobsDir, logsDir } from './lib/paths.mjs'; import { extractChatId, summariseEvents } from './lib/parse.mjs'; +import { interpolateTemplate, loadPromptTemplate } from './lib/prompts.mjs'; +import { parseReviewOutput, stripJsonBlock } from './lib/review-output.mjs'; const BOOLEAN_FLAGS = ['background', 'wait', 'adversarial', 'git-check', 'help']; @@ -40,37 +42,18 @@ function parseFlags(argv) { } export function buildReviewPrompt({ label, body, focus, adversarial }) { - const role = adversarial - ? 'You are a skeptical senior engineer running an ADVERSARIAL review. Challenge whether the chosen implementation and design are the right approach — question assumptions, tradeoffs, and failure modes, not only the implementation defects.' - : 'You are a senior code reviewer.'; - const lines = [role, '', `**Review target:** ${label}`]; - if (focus) lines.push('', `**Reviewer focus (prioritise this):** ${focus}`); - lines.push( - '', - '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).', - ); - if (adversarial) { - lines.push( - '- Challenge the design: is this the right approach? What assumptions does it rest on? Where does it break under real-world load, concurrency, or edge cases?', - '- Would a different approach be simpler, safer, or cheaper to maintain? If so, say what and why — but stay grounded in the diff, do not invent requirements.', - ); - } - lines.push( - '- End with a one-line verdict on its own line: **APPROVE**, **APPROVE WITH NITS**, or **REQUEST CHANGES**.', - '', - '**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.', - ); - return lines.join('\n'); + const template = loadPromptTemplate('review'); + return interpolateTemplate(template, { + ROLE: adversarial + ? 'You are a skeptical senior engineer running an ADVERSARIAL review. Challenge whether the chosen implementation and design are the right approach — question assumptions, tradeoffs, and failure modes, not only the implementation defects.' + : 'You are a senior code reviewer.', + REVIEW_TARGET: label, + FOCUS_BLOCK: focus ? `**Reviewer focus (prioritise this):** ${focus}` : '', + BODY: body, + ADVERSARIAL_GUIDANCE: adversarial + ? '- Challenge the design: is this the right approach? What assumptions does it rest on? Where does it break under real-world load, concurrency, or edge cases?\n- Would a different approach be simpler, safer, or cheaper to maintain? If so, say what and why — but stay grounded in the diff, do not invent requirements.' + : '', + }); } function renderResult(out, { jobId, status, summary, chatId, warnings }) { @@ -127,18 +110,24 @@ async function runReview({ flags, context, jobId, root, onEvent }) { const warnings = postFlightWarnings(summary); const status = result.exitCode === 0 && summary.success && warnings.length === 0 ? 'done' : 'failed'; + // The prompt asks the reviewer to append a fenced ```json verdict block + // (schemas/review-output.schema.json). When it parses, store the data on + // the record and hide the raw fence from the human-facing summary. + const structured = parseReviewOutput(summary.summary ?? ''); + const displaySummary = structured.ok ? stripJsonBlock(summary.summary ?? '') : summary.summary; updateJob(root, jobId, { status, exitCode: result.exitCode, finishedAt: new Date().toISOString(), summary: warnings.length > 0 - ? `${summary.summary}\n\n[plugin post-flight]\n${warnings.join('\n\n')}` - : summary.summary, + ? `${displaySummary}\n\n[plugin post-flight]\n${warnings.join('\n\n')}` + : displaySummary, filesTouched: summary.filesTouched, + ...(structured.ok ? { review: structured.data } : {}), ...(chatId ? { cursorChatId: chatId } : {}), }); - return { result, summary, chatId, warnings, status }; + return { result, summary: { ...summary, summary: displaySummary }, chatId, warnings, status }; } async function foreground(flags, context, jobId, root) { diff --git a/plugins/cursor/scripts/setup.mjs b/plugins/cursor/scripts/setup.mjs index 128f883..71e2681 100644 --- a/plugins/cursor/scripts/setup.mjs +++ b/plugins/cursor/scripts/setup.mjs @@ -3,7 +3,9 @@ import { accessSync, constants as fsConstants, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseCommandArgv } from './lib/args.mjs'; +import { getConfig, setConfigValue } from './lib/config.mjs'; import { authStatus, listConfiguredMcps, listModels, resolveBin } from './lib/cursor.mjs'; +import { repoRoot } from './lib/git.mjs'; import { ensureDir, jobsDir, pluginHome } from './lib/paths.mjs'; import { run } from './lib/run.mjs'; @@ -80,6 +82,17 @@ async function gatherDoctor() { { ok: true, detail: apiKey ? `set (${maskKey(apiKey)})` : 'not set (using local session)' }, ]); + const root = await repoRoot(process.cwd()); + checks.push([ + 'stop review gate', + { + ok: true, + detail: getConfig(root).stopReviewGate + ? 'enabled for this repo (disable with `--disable-review-gate`)' + : 'disabled (enable with `--enable-review-gate`)', + }, + ]); + const mcps = bin ? await listConfiguredMcps() : []; // The CURSOR_API_KEY check is already `ok:true` whether or not the key is @@ -89,6 +102,26 @@ async function gatherDoctor() { return { bin, checks, mcps, allOk }; } +/** + * @param {boolean} enable + * @returns {Promise} + */ +async function toggleReviewGate(enable) { + const root = await repoRoot(process.cwd()); + setConfigValue(root, 'stopReviewGate', enable); + if (enable) { + process.stdout.write( + 'Stop review gate **enabled** for this repository.\n\n' + + 'Before Claude Code ends a turn, a Cursor model reviews the work from that turn; ' + + 'a `BLOCK: …` verdict keeps the session going until the issue is fixed. ' + + 'Disable anytime with `/cursor:setup --disable-review-gate`.\n', + ); + } else { + process.stdout.write('Stop review gate **disabled** for this repository.\n'); + } + return 0; +} + async function doctor(asJson = false) { const { bin, checks, mcps, allOk } = await gatherDoctor(); @@ -193,7 +226,16 @@ async function baseCheck() { * @returns {Promise} */ export async function main(rawArgv) { - const { flags } = parseCommandArgv(rawArgv, ['doctor', 'print-models', 'install', 'json']); + const { flags } = parseCommandArgv(rawArgv, [ + 'doctor', + 'print-models', + 'install', + 'json', + 'enable-review-gate', + 'disable-review-gate', + ]); + if (flags['enable-review-gate'] || flags['enableReviewGate']) return toggleReviewGate(true); + if (flags['disable-review-gate'] || flags['disableReviewGate']) return toggleReviewGate(false); // --json always emits the full structured doctor report — hooks and scripts // branch on `checks[].ok` / `allOk` instead of parsing Markdown. if (flags['json']) return doctor(true); diff --git a/plugins/cursor/scripts/stop-review-gate-hook.mjs b/plugins/cursor/scripts/stop-review-gate-hook.mjs new file mode 100644 index 0000000..1602bdd --- /dev/null +++ b/plugins/cursor/scripts/stop-review-gate-hook.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +// Claude Code Stop hook: optional review gate, off by default. +// +// When enabled (`/cursor:setup --enable-review-gate`), a Cursor model reviews +// the previous Claude turn before the session is allowed to stop. The run +// must answer `ALLOW: …` or `BLOCK: …` on its first line +// (prompts/stop-review-gate.md); a BLOCK is surfaced to Claude Code as +// `{"decision": "block", "reason": …}` so the turn continues with fixes. +// +// The gate never runs when Cursor is missing/unauthenticated (it degrades to +// a stderr note), and `stop_hook_active` short-circuits to ALLOW so a block +// can never loop forever. + +import { readFileSync } from 'node:fs'; +import { getConfig } from './lib/config.mjs'; +import { authStatus, resolveBin, resolveModel, runHeadless } from './lib/cursor.mjs'; +import { repoRoot } from './lib/git.mjs'; +import { id as newId } from './lib/id.mjs'; +import { SESSION_ID_ENV, listJobs, rawLogPath } from './lib/jobs.mjs'; +import { ensureDir, logsDir } from './lib/paths.mjs'; +import { summariseEvents } from './lib/parse.mjs'; +import { interpolateTemplate, loadPromptTemplate } from './lib/prompts.mjs'; +import { invokedAsScript as __isScript } from './lib/invoked.mjs'; + +const GATE_TIMEOUT_SEC = 600; + +/** @returns {Record} */ +function readHookInput() { + try { + const raw = readFileSync(0, 'utf8').trim(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +/** + * First-line ALLOW/BLOCK contract, tolerant of markdown wrapping + * (`**ALLOW: fine**` etc.). + * + * @param {string} text + * @returns {{ok: boolean, reason: string|null}} + */ +export function parseGateOutput(text) { + const firstLine = ( + String(text ?? '') + .trim() + .split(/\r?\n/, 1)[0] ?? '' + ) + .replace(/^[#>*_`\s-]+/, '') + .trim(); + if (/^ALLOW\b/i.test(firstLine)) return { ok: true, reason: null }; + if (/^BLOCK\b/i.test(firstLine)) { + const reason = firstLine.replace(/^BLOCK[:\s]*/i, '').trim() || String(text).trim(); + return { + ok: false, + reason: `Cursor stop-gate review found issues that still need fixes before stopping: ${reason}`, + }; + } + return { + ok: false, + reason: + 'The stop-gate Cursor review returned an unexpected answer. Run `/cursor:review --wait` manually or disable the gate with `/cursor:setup --disable-review-gate`.', + }; +} + +/** + * @param {Record} input + * @param {string} root + * @returns {Promise<{ok: boolean, reason: string|null}>} + */ +async function runGateReview(input, root) { + const template = loadPromptTemplate('stop-review-gate'); + const last = String(input.last_assistant_message ?? '').trim(); + const prompt = interpolateTemplate(template, { + CLAUDE_RESPONSE_BLOCK: last ? `Previous Claude response:\n${last}` : '', + }); + ensureDir(logsDir(root)); + const result = await runHeadless({ + prompt, + model: resolveModel(undefined), + cloud: false, + force: true, + timeoutSec: GATE_TIMEOUT_SEC, + logPath: rawLogPath(root, `gate-${newId(8)}`), + cwd: root, + }); + if (result.killed) { + return { + ok: false, + reason: `The stop-gate Cursor review timed out after ${GATE_TIMEOUT_SEC / 60} minutes. Run \`/cursor:review --wait\` manually or disable the gate with \`/cursor:setup --disable-review-gate\`.`, + }; + } + const summary = summariseEvents(result.events); + if (result.exitCode !== 0 || !summary.success) { + return { + ok: false, + reason: `The stop-gate Cursor review failed (exit ${result.exitCode}). Run \`/cursor:review --wait\` manually or disable the gate with \`/cursor:setup --disable-review-gate\`.`, + }; + } + return parseGateOutput(summary.summary ?? ''); +} + +/** + * @param {Record} input + * @param {{out?: (s: string) => void, err?: (s: string) => void}} [io] + * @returns {Promise} + */ +export async function handleStop(input, io = {}) { + const out = io.out ?? ((s) => process.stdout.write(s)); + const err = io.err ?? ((s) => process.stderr.write(s)); + + // Claude Code sets stop_hook_active when the turn is already continuing + // because a stop hook blocked — never block again, or the gate would loop. + if (input.stop_hook_active) return 0; + + const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd(); + const root = await repoRoot(cwd); + + const sessionId = + (typeof input.session_id === 'string' && input.session_id) || process.env[SESSION_ID_ENV]; + const running = listJobs(root).filter( + (j) => j.status === 'running' && (!sessionId || !j.sessionId || j.sessionId === sessionId), + ); + const runningNote = + running.length > 0 + ? `Cursor job(s) still running: ${running.map((j) => `\`${j.id}\``).join(', ')} — check \`/cursor:status\`, cancel with \`/cursor:cancel \` if unwanted.` + : null; + + if (!getConfig(root).stopReviewGate) { + if (runningNote) err(runningNote + '\n'); + return 0; + } + + // Preflight: a misconfigured Cursor must degrade to a note, not a block. + try { + await resolveBin(); + } catch { + err('Stop review gate is enabled but cursor-agent is not installed. Run /cursor:setup.\n'); + if (runningNote) err(runningNote + '\n'); + return 0; + } + const auth = await authStatus(); + if (!auth.loggedIn) { + err('Stop review gate is enabled but Cursor CLI is not logged in. Run `cursor-agent login`.\n'); + if (runningNote) err(runningNote + '\n'); + return 0; + } + + const review = await runGateReview(input, root); + if (!review.ok) { + out( + JSON.stringify({ + decision: 'block', + reason: runningNote ? `${runningNote} ${review.reason}` : review.reason, + }) + '\n', + ); + return 0; + } + if (runningNote) err(runningNote + '\n'); + return 0; +} + +const invokedAsScript = __isScript(import.meta.url); + +if (invokedAsScript) { + handleStop(readHookInput()) + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write( + `stop-review-gate failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + // Hook infrastructure failures must never trap the user in a session. + process.exit(0); + }); +} diff --git a/plugins/cursor/tests/config.test.mjs b/plugins/cursor/tests/config.test.mjs new file mode 100644 index 0000000..ac15fb5 --- /dev/null +++ b/plugins/cursor/tests/config.test.mjs @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { configPath, getConfig, setConfigValue } from '../scripts/lib/config.mjs'; +import { makeTempHome } from './helpers.mjs'; + +describe('config', () => { + let tmp; + const prevHome = process.env.CURSOR_PLUGIN_CC_HOME; + const repoA = '/tmp/repo-a'; + const repoB = '/tmp/repo-b'; + + beforeEach(() => { + tmp = makeTempHome(); + process.env.CURSOR_PLUGIN_CC_HOME = tmp.dir; + }); + + afterEach(() => { + if (prevHome === undefined) delete process.env.CURSOR_PLUGIN_CC_HOME; + else process.env.CURSOR_PLUGIN_CC_HOME = prevHome; + tmp.cleanup(); + }); + + it('defaults to stopReviewGate: false', () => { + expect(getConfig(repoA)).toEqual({ stopReviewGate: false }); + }); + + it('persists a toggled value per repo', () => { + setConfigValue(repoA, 'stopReviewGate', true); + expect(getConfig(repoA).stopReviewGate).toBe(true); + expect(getConfig(repoB).stopReviewGate).toBe(false); + setConfigValue(repoA, 'stopReviewGate', false); + expect(getConfig(repoA).stopReviewGate).toBe(false); + }); + + it('keeps config outside the jobs dir', () => { + expect(configPath(repoA)).toContain('/config/'); + expect(configPath(repoA)).not.toContain('/jobs/'); + }); +}); diff --git a/plugins/cursor/tests/fixtures/cursor-agent-stub.mjs b/plugins/cursor/tests/fixtures/cursor-agent-stub.mjs index fafee76..312b9cb 100755 --- a/plugins/cursor/tests/fixtures/cursor-agent-stub.mjs +++ b/plugins/cursor/tests/fixtures/cursor-agent-stub.mjs @@ -3,6 +3,17 @@ // the CURSOR_AGENT_STUB_FIXTURE env var, then exits. import { readFileSync } from 'node:fs'; +// `cursor-agent status` — auth check used by authStatus(). Controlled by +// CURSOR_AGENT_STUB_AUTH: 'out' simulates a logged-out CLI. +if (process.argv[2] === 'status') { + if (process.env.CURSOR_AGENT_STUB_AUTH === 'out') { + process.stdout.write('Not logged in\n'); + process.exit(1); + } + process.stdout.write('Logged in as test-stub\n'); + process.exit(0); +} + const fixture = process.env.CURSOR_AGENT_STUB_FIXTURE; if (!fixture) { process.stderr.write('stub: CURSOR_AGENT_STUB_FIXTURE not set\n'); diff --git a/plugins/cursor/tests/fixtures/cursor-events/gate-allow.ndjson b/plugins/cursor/tests/fixtures/cursor-events/gate-allow.ndjson new file mode 100644 index 0000000..e8748ce --- /dev/null +++ b/plugins/cursor/tests/fixtures/cursor-events/gate-allow.ndjson @@ -0,0 +1,2 @@ +{"type":"system","subtype":"init","session_id":"chat_gate_001","cwd":"/tmp/repo"} +{"type":"result","subtype":"success","chat_id":"chat_gate_001","result":"ALLOW: previous turn made no code changes."} diff --git a/plugins/cursor/tests/fixtures/cursor-events/gate-block.ndjson b/plugins/cursor/tests/fixtures/cursor-events/gate-block.ndjson new file mode 100644 index 0000000..291d12e --- /dev/null +++ b/plugins/cursor/tests/fixtures/cursor-events/gate-block.ndjson @@ -0,0 +1,2 @@ +{"type":"system","subtype":"init","session_id":"chat_gate_002","cwd":"/tmp/repo"} +{"type":"result","subtype":"success","chat_id":"chat_gate_002","result":"BLOCK: delegate.mjs timeout path has no test and the error branch swallows the exit code."} diff --git a/plugins/cursor/tests/gate.test.mjs b/plugins/cursor/tests/gate.test.mjs new file mode 100644 index 0000000..f1d07d6 --- /dev/null +++ b/plugins/cursor/tests/gate.test.mjs @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setConfigValue } from '../scripts/lib/config.mjs'; +import { handleStop, parseGateOutput } from '../scripts/stop-review-gate-hook.mjs'; +import { STUB_BIN, makeTempHome } from './helpers.mjs'; + +const GATE_ALLOW_FIXTURE = new URL('./fixtures/cursor-events/gate-allow.ndjson', import.meta.url) + .pathname; +const GATE_BLOCK_FIXTURE = new URL('./fixtures/cursor-events/gate-block.ndjson', import.meta.url) + .pathname; + +describe('stop review gate', () => { + let tmp; + let stdout; + let stderr; + const io = { + out: (s) => { + stdout += s; + }, + err: (s) => { + stderr += s; + }, + }; + const prevHome = process.env.CURSOR_PLUGIN_CC_HOME; + const prevBin = process.env.CURSOR_AGENT_BIN; + const prevFix = process.env.CURSOR_AGENT_STUB_FIXTURE; + const prevAuth = process.env.CURSOR_AGENT_STUB_AUTH; + + beforeEach(() => { + tmp = makeTempHome(); + stdout = ''; + stderr = ''; + process.env.CURSOR_PLUGIN_CC_HOME = tmp.dir; + process.env.CURSOR_AGENT_BIN = STUB_BIN; + process.env.CURSOR_AGENT_STUB_FIXTURE = GATE_ALLOW_FIXTURE; + delete process.env.CURSOR_AGENT_STUB_AUTH; + }); + + afterEach(() => { + if (prevHome === undefined) delete process.env.CURSOR_PLUGIN_CC_HOME; + else process.env.CURSOR_PLUGIN_CC_HOME = prevHome; + if (prevBin === undefined) delete process.env.CURSOR_AGENT_BIN; + else process.env.CURSOR_AGENT_BIN = prevBin; + if (prevFix === undefined) delete process.env.CURSOR_AGENT_STUB_FIXTURE; + else process.env.CURSOR_AGENT_STUB_FIXTURE = prevFix; + if (prevAuth === undefined) delete process.env.CURSOR_AGENT_STUB_AUTH; + else process.env.CURSOR_AGENT_STUB_AUTH = prevAuth; + tmp.cleanup(); + }); + + describe('parseGateOutput', () => { + it('accepts ALLOW and BLOCK first lines, with markdown tolerance', () => { + expect(parseGateOutput('ALLOW: fine').ok).toBe(true); + expect(parseGateOutput('**ALLOW: fine**').ok).toBe(true); + const block = parseGateOutput('BLOCK: missing test\nmore detail'); + expect(block.ok).toBe(false); + expect(block.reason).toContain('missing test'); + }); + + it('treats anything else as unexpected (fails closed)', () => { + const res = parseGateOutput('I reviewed the code and it looks good.'); + expect(res.ok).toBe(false); + expect(res.reason).toContain('unexpected answer'); + }); + }); + + it('does nothing when the gate is disabled', async () => { + await handleStop({ cwd: tmp.dir }, io); + expect(stdout).toBe(''); + }); + + it('short-circuits when stop_hook_active is set (no loop)', async () => { + setConfigValue(tmp.dir, 'stopReviewGate', true); + await handleStop({ cwd: tmp.dir, stop_hook_active: true }, io); + expect(stdout).toBe(''); + }); + + it('allows silently on an ALLOW verdict', async () => { + setConfigValue(tmp.dir, 'stopReviewGate', true); + await handleStop({ cwd: tmp.dir, last_assistant_message: 'edited foo.ts' }, io); + expect(stdout).toBe(''); + }); + + it('emits a block decision on a BLOCK verdict', async () => { + setConfigValue(tmp.dir, 'stopReviewGate', true); + process.env.CURSOR_AGENT_STUB_FIXTURE = GATE_BLOCK_FIXTURE; + await handleStop({ cwd: tmp.dir, last_assistant_message: 'edited foo.ts' }, io); + const decision = JSON.parse(stdout); + expect(decision.decision).toBe('block'); + expect(decision.reason).toContain('timeout path has no test'); + }); + + it('degrades to a stderr note when Cursor CLI is logged out', async () => { + setConfigValue(tmp.dir, 'stopReviewGate', true); + process.env.CURSOR_AGENT_STUB_AUTH = 'out'; + await handleStop({ cwd: tmp.dir }, io); + expect(stdout).toBe(''); + expect(stderr).toContain('cursor-agent login'); + }); +}); diff --git a/plugins/cursor/tests/prompts.test.mjs b/plugins/cursor/tests/prompts.test.mjs new file mode 100644 index 0000000..1dfea8f --- /dev/null +++ b/plugins/cursor/tests/prompts.test.mjs @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { interpolateTemplate, loadPromptTemplate } from '../scripts/lib/prompts.mjs'; + +describe('prompts', () => { + it('interpolates known keys and blanks unknown ones', () => { + const out = interpolateTemplate('a {{FOO}} b {{MISSING}} c', { FOO: 'x' }); + expect(out).toBe('a x b c'); + }); + + it('collapses blank-line runs left by empty optional blocks', () => { + const out = interpolateTemplate('line1\n\n{{OPT}}\n\nline2', { OPT: '' }); + expect(out).toBe('line1\n\nline2'); + }); + + it('loads the shipped templates', () => { + const review = loadPromptTemplate('review'); + expect(review).toContain('{{REVIEW_TARGET}}'); + expect(review).toContain('READ-ONLY review'); + const gate = loadPromptTemplate('stop-review-gate'); + expect(gate).toContain('{{CLAUDE_RESPONSE_BLOCK}}'); + expect(gate).toContain('ALLOW:'); + expect(gate).toContain('BLOCK:'); + }); +}); diff --git a/plugins/cursor/tests/review-output.test.mjs b/plugins/cursor/tests/review-output.test.mjs new file mode 100644 index 0000000..59746a6 --- /dev/null +++ b/plugins/cursor/tests/review-output.test.mjs @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + extractJsonBlock, + parseReviewOutput, + stripJsonBlock, +} from '../scripts/lib/review-output.mjs'; + +const GOOD = { + verdict: 'approve-with-nits', + summary: 'Solid change, one naming nit.', + findings: [ + { + severity: 'nit', + title: 'Magic number', + file: 'src/foo.ts', + line: 12, + body: '42 should be a named constant.', + recommendation: 'Extract MAX_RETRIES.', + }, + ], + next_steps: ['Rename the constant.'], +}; + +function withBlock(obj) { + return `## Findings\n\nsome markdown\n\nAPPROVE WITH NITS\n\n\`\`\`json\n${JSON.stringify(obj, null, 2)}\n\`\`\`\n`; +} + +describe('review-output', () => { + it('parses a valid trailing json block', () => { + const res = parseReviewOutput(withBlock(GOOD)); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.data.verdict).toBe('approve-with-nits'); + expect(res.data.findings).toHaveLength(1); + expect(res.data.next_steps).toEqual(['Rename the constant.']); + } + }); + + it('uses the LAST json block when several are present', () => { + const text = '```json\n{"quoted": "diff content"}\n```\n' + withBlock(GOOD); + const res = parseReviewOutput(text); + expect(res.ok).toBe(true); + expect(extractJsonBlock(text)).toContain('approve-with-nits'); + }); + + it('rejects an unknown verdict', () => { + const res = parseReviewOutput(withBlock({ ...GOOD, verdict: 'ship-it' })); + expect(res.ok).toBe(false); + }); + + it('rejects malformed findings', () => { + const res = parseReviewOutput( + withBlock({ ...GOOD, findings: [{ severity: 'blocking', title: 'x' }] }), + ); + expect(res.ok).toBe(false); + }); + + it('accepts null/absent line and missing next_steps', () => { + const res = parseReviewOutput( + withBlock({ + verdict: 'approve', + summary: 'ok', + findings: [{ severity: 'nit', title: 't', file: 'f', body: 'b', line: null }], + }), + ); + expect(res.ok).toBe(true); + if (res.ok) expect(res.data.next_steps).toEqual([]); + }); + + it('reports missing block and broken JSON distinctly', () => { + expect(parseReviewOutput('plain markdown only').ok).toBe(false); + const broken = 'text\n```json\n{not json\n```\n'; + const res = parseReviewOutput(broken); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain('invalid JSON'); + }); + + it('stripJsonBlock removes only the trailing block', () => { + const text = withBlock(GOOD); + const stripped = stripJsonBlock(text); + expect(stripped).toContain('APPROVE WITH NITS'); + expect(stripped).not.toContain('```json'); + // No block → unchanged. + expect(stripJsonBlock('no block here')).toBe('no block here'); + }); +});