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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ 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

### Fixed

- **`/cursor:from-plan` no longer drops sections that map to no task slot** (#16). 0.5.1's verbatim pass-through only fires when _zero_ intents match, so a spec that partially matches still lost everything else. A PRP is the clearest case: `Why` → context, `Implementation Blueprint` → approach and `Validation Loop` → verification all resolve, so `Goal`, `What` / success criteria and `All Needed Context` — including its `CRITICAL:` gotchas — were discarded with no warning. Unclaimed sections are now carried through under `## Additional specification context`, preserving the author's original heading casing; only reviewer-facing commentary (`Effort / risks`, `Open questions`, `Alternatives considered`) is still dropped. A `## Goal` section now fills the Goal slot instead of the task file echoing its own title.

### Changed

- **The task destination is now derived from the spec instead of imposed by config** (#16). Point `/cursor:from-plan` at a project spec and the task file is written **next to it** — `PRPs/018-wizard.md` produces `PRPs/<stamp>-018-wizard.md`, beside its siblings — so spec-driven projects need no configuration at all. Full resolution order: `--out-dir <dir>` → the spec's own directory → `CURSOR_PLUGIN_CC_TASKS_DIR` → the per-repo `tasksDir` config → `tasks/`. The last three now only apply to Claude's own plan-mode files, which have no project location to inherit. This follows upstream [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc), which never writes into the user's project tree at all — `tasks/` was an addition of this fork, not something inherited.

### Added

- **Multi-repo support: the spec and the code no longer have to share a repository** (#16). A central spec directory (one monorepo holding PRPs for several sibling service repos) is now a first-class case. Running `/cursor:from-plan ~/work/mono/PRPs/018.md` from inside `~/work/api` writes the task file beside 001–017 in the monorepo — the API repo gets no stray `PRPs/` or `tasks/` folder — and hands Cursor an **absolute path** to it, since `@path` cannot resolve outside the repo `cursor-agent` runs in. Code changes still land in the invoking repo. Previously `--out-dir PRPs` silently created a second, disconnected `PRPs/` tree in the wrong repo, and `--in-place` refused outright.
- **`/cursor:setup --tasks-dir <dir>`** (and `--no-tasks-dir`) persists a per-repo fallback directory, surfaced in `--doctor`. Rarely needed now that the destination is derived; it applies only to plan-mode files.
- **`/cursor:from-plan --in-place`** delegates the source spec exactly as written and creates no task file, for workflows where the spec already _is_ the task. Works across repos.

Defaults are unchanged for the plan-mode flow: with no spec path, no flag, no env var and no config, output still lands in `tasks/` and plan-mode conversions are byte-identical.

## 0.5.1 — from-plan pass-through for external spec formats

### Fixed
Expand Down
96 changes: 62 additions & 34 deletions README.md

Large diffs are not rendered by default.

22 changes: 18 additions & 4 deletions plugins/cursor/commands/from-plan.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
---
description: Convert a Claude Code plan file into a tasks/<file>.md and optionally hand it off to Cursor.
argument-hint: '[plan-name-fragment] [--delegate] [--model <id>] [--background] [--list]'
description: Convert a Claude Code plan (or any spec file) into a task file and optionally hand it off to Cursor.
argument-hint: '[plan-name-fragment|path] [--delegate] [--out-dir <dir>] [--in-place] [--model <id>] [--background] [--list]'
allowed-tools: Bash(node:*)
---

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

Render the output verbatim. The command has two modes:

- **Preview + hand back** (default): it writes `tasks/<file>.md`, then prints the exact `/cursor:delegate @tasks/…` command. Run that command yourself after reviewing the task file.
- **Preview + hand back** (default): it writes the task file, then prints the exact `/cursor:delegate @…` command. Run that command yourself after reviewing the task file.
- **Auto-delegate** (`--delegate` or `--yes`): it writes the task file AND immediately calls `/cursor:delegate`, so the output merges into a single flow.

Without arguments it picks the newest plan under `~/.claude/plans/`. Pass a name fragment (e.g. `dark-mode`) to pick a specific one. `--list` shows the 15 most recent plan files.
Without arguments it picks the newest plan under `~/.claude/plans/`. Pass a name fragment (e.g. `dark-mode`) or any path (e.g. `PRPs/dark-mode.md`) to pick a specific one. `--list` shows the 15 most recent plan files.

**Where the task file lands** — derived from the spec, most- to least-specific:

1. `--out-dir <dir>` for a single run
2. **the directory the source spec lives in** — pass `PRPs/018.md` and the task file joins it there, in whichever repo that is
3. the `CURSOR_PLUGIN_CC_TASKS_DIR` environment variable
4. the per-repo default: `/cursor:setup --tasks-dir PRPs` (reset with `--no-tasks-dir`)
5. `tasks/`

Rule 2 means spec-driven projects need no configuration, and it keeps working when specs are centralised in one repo while code lives in siblings. Rules 3–5 only come into play for Claude's own plan-mode files, which have no project location to inherit. Relative values resolve against the repo root, so the destination does not move when you run the command from a subdirectory.

The spec may live **outside** the current repo. When it does, Cursor is handed an absolute path instead of `@path` (which cannot leave the repo root), and told to apply its changes in the current repository. Report that note to the user when it appears.

**`--in-place`** skips the conversion entirely and delegates the source document as written — no task file is created. Use it when your spec already _is_ the task (PRP, Spec Kit, OpenSpec). Works across repos.
4 changes: 3 additions & 1 deletion plugins/cursor/commands/setup.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
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]'
argument-hint: '[--doctor] [--print-models] [--install] [--json] [--enable-review-gate|--disable-review-gate] [--tasks-dir <dir>]'
allowed-tools: Bash(node:*)
---

Expand All @@ -9,3 +9,5 @@ allowed-tools: Bash(node:*)
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.

`--tasks-dir <dir>` sets, per repository, a fallback directory for `/cursor:from-plan` task files. Most projects do not need it: when you pass a spec path, the task file is written next to that spec automatically, in whichever repo it lives. This key only applies to Claude's own plan-mode files, which have no project location to inherit. `--no-tasks-dir` resets it. Relay the confirmation message verbatim.
174 changes: 155 additions & 19 deletions plugins/cursor/scripts/from-plan.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
#!/usr/bin/env node
// /cursor:from-plan — convert a Claude Code plan file into a task file under
// `tasks/` in the current repo, and optionally hand it off to Cursor via
// `/cursor:delegate @tasks/<file>`.
// /cursor:from-plan — convert a Claude Code plan file into a task file inside
// the repo, and optionally hand it off to Cursor via `/cursor:delegate @<file>`.
//
// The destination is derived, not imposed. When the plan is a spec from the
// user's project, the task file is written beside it — a spec already knows
// where it belongs, and that answer stays correct no matter which repository
// the command runs from. `tasks/` is only the fallback for Claude's own
// plan-mode files, which have no project location to inherit; `--out-dir`,
// `CURSOR_PLUGIN_CC_TASKS_DIR` and the per-repo `tasksDir` config override.
// `--in-place` skips the generated file altogether.
//
// None of this assumes the spec and the code share a repository. A central
// spec directory (a monorepo holding PRPs for several sibling service repos)
// is a first-class case: the task file lands next to its siblings, and Cursor
// receives an absolute path to it rather than an unreachable `@path`.
//
// Typical flow:
// 1. User runs /plan mode in Claude Code, Claude writes a plan to
Expand All @@ -16,11 +28,18 @@

import { existsSync, writeFileSync } from 'node:fs';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { collapseCommandArgv, parseArgv, parseTimeout } from './lib/args.mjs';
import { main as delegateMain } from './delegate.mjs';
import { getConfig } from './lib/config.mjs';
import { isGitRepo, repoRoot } from './lib/git.mjs';
import { buildTaskContent, listPlans, parsePlanFile, resolvePlanPath } from './lib/plan.mjs';
import {
buildTaskContent,
isPlanModeFile,
listPlans,
parsePlanFile,
resolvePlanPath,
} from './lib/plan.mjs';
import { invokedAsScript as __isScript } from './lib/invoked.mjs';

const BOOLEAN_FLAGS = [
Expand All @@ -32,8 +51,11 @@ const BOOLEAN_FLAGS = [
'git-check',
'list',
'help',
'in-place',
];

export const DEFAULT_TASKS_DIR = 'tasks';

function parseFlags(argv) {
const { positional, flags } = parseArgv(argv, BOOLEAN_FLAGS);
const shouldDelegate = flags['delegate'] === true || flags['yes'] === true || flags['y'] === true;
Expand All @@ -45,6 +67,13 @@ function parseFlags(argv) {
const list = Boolean(flags['list']);
const model = typeof flags['model'] === 'string' ? flags['model'] : undefined;
const timeout = 'timeout' in flags ? parseTimeout(flags['timeout']) : undefined;
const outDir =
typeof flags['outDir'] === 'string'
? flags['outDir']
: typeof flags['out-dir'] === 'string'
? flags['out-dir']
: undefined;
const inPlace = Boolean(flags['inPlace'] || flags['in-place']);
const planRef = positional[0];
return {
planRef,
Expand All @@ -56,9 +85,83 @@ function parseFlags(argv) {
list,
model,
timeout,
outDir,
inPlace,
};
}

/**
* Resolve where the generated task file lands, most- to least-explicit:
* 1. `--out-dir <dir>`
* 2. the directory the source spec itself lives in
* 3. `CURSOR_PLUGIN_CC_TASKS_DIR`
* 4. the per-repo `tasksDir` config key (`/cursor:setup --tasks-dir <dir>`)
* 5. `tasks/`
*
* Rule 2 is what makes this work across repositories. A project spec already
* says where it belongs, so the task file is written next to it — `PRPs/018.md`
* produces `PRPs/<stamp>-018.md`, beside its siblings, whichever repo the
* command runs from. Configuration is only consulted for Claude's own
* plan-mode files, which have no project location to inherit.
*
* A relative value is resolved against the repo root, not the CWD, so the
* destination does not move when the command runs from a subdirectory.
*
* @param {string} root
* @param {string|undefined} flagValue
* @param {string} [planPath] Resolved source plan; supplies rule 2.
* @returns {{ dir: string, source: 'flag'|'spec'|'env'|'config'|'default' }}
*/
export function resolveTasksDir(root, flagValue, planPath) {
const env = process.env.CURSOR_PLUGIN_CC_TASKS_DIR;
const specDir = planPath && !isPlanModeFile(planPath) ? dirname(resolve(planPath)) : undefined;
/** @type {Array<['flag'|'spec'|'env'|'config'|'default', string|null|undefined]>} */
const candidates = [
['flag', flagValue],
['spec', specDir],
['env', env && env.trim() ? env.trim() : undefined],
['config', getConfig(root).tasksDir],
['default', DEFAULT_TASKS_DIR],
];
for (const [source, value] of candidates) {
if (typeof value !== 'string' || !value.trim()) continue;
const raw = value.trim();
return { dir: isAbsolute(raw) ? raw : resolve(root, raw), source };
}
return { dir: resolve(root, DEFAULT_TASKS_DIR), source: 'default' };
}

/**
* Build the reference Cursor should follow to reach a file.
*
* Inside the repo we use the `@path` shorthand Cursor resolves from the repo
* root. Outside it — a spec kept in a sibling monorepo while the code lives
* here — `@path` cannot reach, so we hand over the absolute path instead;
* `cursor-agent` reads those fine. This is what lets one central spec
* directory drive work in many repositories.
*
* @param {string} root
* @param {string} fullPath
* @returns {{ mention: string, display: string, inside: boolean }}
*/
export function referenceFor(root, fullPath) {
const rel = relative(root, fullPath);
const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
return inside
? { mention: `@${rel}`, display: rel, inside: true }
: { mention: fullPath, display: fullPath, inside: false };
}

/**
* @param {{ mention: string, inside: boolean }} ref
* @returns {string}
*/
function delegatePrompt(ref) {
return ref.inside
? `Implement the task described in ${ref.mention}. Follow every section.`
: `Read the specification file at ${ref.mention} (it lives outside this repository) and implement it here. Follow every section. Apply all code changes in the current repository.`;
}

function timestamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
Expand Down Expand Up @@ -92,8 +195,7 @@ function renderPlansList() {
return lines.join('\n') + '\n';
}

function writeTaskFile(root, slug, content) {
const tasksDir = join(root, 'tasks');
function writeTaskFile(tasksDir, slug, content) {
if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
const stamp = timestamp();
// Avoid clobbering a task generated for the same plan in the same second.
Expand Down Expand Up @@ -144,40 +246,74 @@ export async function main(rawArgv) {
return 2;
}

const taskContent = buildTaskContent(plan);
const { fullPath } = writeTaskFile(root, plan.slug, taskContent);
const relPath = fullPath.startsWith(root + '/') ? fullPath.slice(root.length + 1) : fullPath;
// `--in-place`: the spec IS the task. Write nothing, delegate the source
// document as-is. This is the lossless path for PRP / Spec Kit / OpenSpec
// workflows, where a converted copy would only drift from the original.
// The spec may live outside this repo (a central spec directory driving many
// repos) — `referenceFor` hands Cursor an absolute path in that case.
let ref;
if (flags.inPlace) {
ref = referenceFor(root, planPath);
process.stdout.write(`### Delegating the spec in place\n\n`);
process.stdout.write(`- **Spec:** \`${ref.display}\` (no task file written)\n`);
if (!ref.inside) {
process.stdout.write(
`- **Note:** the spec lives outside this repo; Cursor reads it by absolute path and applies changes here.\n`,
);
}
process.stdout.write(`- **Title:** ${plan.title || '(untitled)'}\n\n`);
} else {
const { dir: tasksDir, source } = resolveTasksDir(root, flags.outDir, planPath);
const taskContent = buildTaskContent(plan);
const { fullPath } = writeTaskFile(tasksDir, plan.slug, taskContent);
ref = referenceFor(root, fullPath);

process.stdout.write(`### Task file created\n\n`);
process.stdout.write(`- **Source plan:** \`${planPath}\`\n`);
process.stdout.write(`- **Task file:** \`${relPath}\`\n`);
process.stdout.write(`- **Title:** ${plan.title || '(untitled)'}\n\n`);
process.stdout.write(`### Task file created\n\n`);
process.stdout.write(`- **Source plan:** \`${planPath}\`\n`);
process.stdout.write(`- **Task file:** \`${ref.display}\`\n`);
if (source !== 'default') {
const label = {
flag: '--out-dir',
spec: "the source spec's own directory",
env: 'CURSOR_PLUGIN_CC_TASKS_DIR',
config: 'repo config',
};
process.stdout.write(`- **Output directory:** from ${label[source]}\n`);
}
process.stdout.write(`- **Title:** ${plan.title || '(untitled)'}\n\n`);
}

if (!flags.shouldDelegate) {
process.stdout.write('---\n\n');
process.stdout.write('Review the task file, then delegate it to Cursor:\n\n');
process.stdout.write(
flags.inPlace
? 'Review the spec, then delegate it to Cursor:\n\n'
: 'Review the task file, then delegate it to Cursor:\n\n',
);
process.stdout.write('```\n');
const modelFlag = flags.model ? ` --model ${flags.model}` : '';
const bgFlag = flags.background ? ' --background' : '';
const freshFlag = flags.fresh ? ' --fresh' : '';
process.stdout.write(`/cursor:delegate${modelFlag}${bgFlag}${freshFlag} @${relPath}\n`);
process.stdout.write(
`/cursor:delegate${modelFlag}${bgFlag}${freshFlag} ${delegatePrompt(ref)}\n`,
);
process.stdout.write('```\n\n');
process.stdout.write(
'Re-run with `--delegate` (or `--yes`) to skip the review and hand it off right away.\n',
);
return 0;
}

// Auto-delegate: call delegate.mjs in-process. We pass the task file as
// part of the prompt using the same `@path` convention Claude Code uses.
// Auto-delegate: call delegate.mjs in-process. Inside the repo we use the
// `@path` convention Claude Code uses; outside it, an absolute path.
const delegateArgs = [];
if (flags.noGitCheck) delegateArgs.push('--no-git-check');
if (flags.model) delegateArgs.push('--model', flags.model);
if (flags.background) delegateArgs.push('--background');
if (flags.fresh) delegateArgs.push('--fresh');
if (!flags.force) delegateArgs.push('--no-force');
if (typeof flags.timeout === 'number') delegateArgs.push('--timeout', String(flags.timeout));
delegateArgs.push('--', `Implement the task described in @${relPath}. Follow every section.`);
delegateArgs.push('--', delegatePrompt(ref));

process.stdout.write('---\n\nHanding off to Cursor…\n\n');
return delegateMain(delegateArgs);
Expand Down
10 changes: 7 additions & 3 deletions plugins/cursor/scripts/lib/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureDir, pluginHome, repoHash } from './paths.mjs';

const DEFAULTS = Object.freeze({ stopReviewGate: false });
// `tasksDir` is null until the user opts in, which keeps `tasks/` as the
// default for existing repos while letting spec-driven workflows (PRPs/,
// specs/, openspec/) point the generated task file at the folder they already
// use, instead of growing a second parallel tree.
const DEFAULTS = Object.freeze({ stopReviewGate: false, tasksDir: null });

/**
* @param {string} repoPath
Expand All @@ -18,7 +22,7 @@ export function configPath(repoPath) {

/**
* @param {string} repoPath
* @returns {{stopReviewGate: boolean}}
* @returns {{stopReviewGate: boolean, tasksDir: string|null}}
*/
export function getConfig(repoPath) {
try {
Expand All @@ -32,7 +36,7 @@ export function getConfig(repoPath) {
* @param {string} repoPath
* @param {string} key
* @param {unknown} value
* @returns {{stopReviewGate: boolean}}
* @returns {{stopReviewGate: boolean, tasksDir: string|null}}
*/
export function setConfigValue(repoPath, key, value) {
ensureDir(join(pluginHome(), 'config'));
Expand Down
Loading