diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f0f1a9..0283f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/-018-wizard.md`, beside its siblings — so spec-driven projects need no configuration at all. Full resolution order: `--out-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 `** (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 diff --git a/README.md b/README.md index f5b49ac..b0a43e1 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,11 @@ This is the entire happy path. Three commands inside Claude Code: What lives where after a run: -| Path | Written by | Purpose | -| ---- | ---------- | ------- | -| `~/.claude/plans/.md` | Claude (plan mode) | Source of truth for the plan | -| `tasks/-.md` | This plugin | Cursor-shaped contract — **commit this** | -| `` | Cursor | The actual code change | +| Path | Written by | Purpose | +| --------------------------------- | ------------------ | ---------------------------------------- | +| `~/.claude/plans/.md` | Claude (plan mode) | Source of truth for the plan | +| `tasks/-.md` | This plugin | Cursor-shaped contract — **commit this** | +| `` | Cursor | The actual code change | **Iterate:** if Claude's review of the diff finds something, just run `/cursor:resume "fix X"` — same Cursor chat, no replanning. For a fresh slice, run `/cursor:from-plan --delegate` again with a new plan, or `/cursor:delegate ""` directly without plan mode. @@ -155,17 +155,17 @@ It is modelled on [`openai/codex-plugin-cc`](https://github.com/openai/codex-plu Hand a coding task to `cursor-agent -p …`. -| Flag | Default | Effect | -| ---------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Flag | Default | Effect | +| ---------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--model ` | `auto` (or `$CURSOR_PLUGIN_CC_DEFAULT_MODEL`) | Aliases → real Cursor ids: `composer`/`fast` → `composer-2.5-fast`, `composer-full` → `composer-2.5`, `sonnet` → `claude-4.6-sonnet-medium`, `opus` → `claude-opus-4-7-high`, `gpt`/`codex` → `gpt-5.3-codex`, `grok` → `grok-4.3`, `gemini` → `gemini-3.1-pro`, `auto` → `auto`. Unknown or retired ids (e.g. `composer-2`, `composer-2-fast`) are forwarded as-is. `auto` lets Cursor pick whatever model your account is entitled to — safe if you don't have a Composer seat. Run `/cursor:setup --print-models` for the live list. | -| `--background` | off | Detach; the command returns a job id immediately. | -| `--wait` | on (if not `--background`) | Block until finished. | -| `--fresh` | off | Start a brand-new Cursor session (no resume). | -| `--resume[=]` | off | Resume a prior chat. With no id, resume the latest for this repo. | -| `--no-force` | `--force` is ON | Disable auto-approve (paranoid mode). | -| `--cloud` | off | Pass `-c` to cursor-agent. | -| `--timeout ` | `1800` | Kill the job if it exceeds this. | -| `--no-git-check` | off | Allow running outside a git repo. | +| `--background` | off | Detach; the command returns a job id immediately. | +| `--wait` | on (if not `--background`) | Block until finished. | +| `--fresh` | off | Start a brand-new Cursor session (no resume). | +| `--resume[=]` | off | Resume a prior chat. With no id, resume the latest for this repo. | +| `--no-force` | `--force` is ON | Disable auto-approve (paranoid mode). | +| `--cloud` | off | Pass `-c` to cursor-agent. | +| `--timeout ` | `1800` | Kill the job if it exceeds this. | +| `--no-git-check` | off | Allow running outside a git repo. | Examples: @@ -194,6 +194,8 @@ Examples: /cursor:from-plan # newest plan → tasks/ → print delegate command /cursor:from-plan dark-mode # match on a plan name fragment /cursor:from-plan --delegate # newest plan → tasks/ → delegate immediately +/cursor:from-plan --out-dir PRPs # write the task file next to your specs instead +/cursor:from-plan --in-place PRPs/x.md # no task file at all — delegate the spec as written /cursor:from-plan --delegate --model opus --background "some-slug" /cursor:from-plan --list # show the 15 most recent plans ``` @@ -205,7 +207,33 @@ Examples: /cursor:from-plan --delegate docs/rfc-042.md ``` -Sections named like `Overview`, `Requirements`, `Design`, `Tasks`, or `Testing` are mapped onto the task shape; a document whose structure isn't recognised at all is embedded **verbatim** (with the guardrail block appended) instead of being reduced to placeholders. And if you don't need the task-file conversion, `/cursor:delegate "Implement @specs/checkout-flow/plan.md"` sends the file to Cursor directly. +Sections named like `Overview`, `Requirements`, `Design`, `Tasks`, or `Testing` are mapped onto the task shape. **Sections that map to nothing are never dropped** — they are carried through under `## Additional specification context`, so a format richer than Claude's plan-mode shape (a PRP's `All Needed Context` and its `CRITICAL:` gotchas, Spec Kit's `Clarifications`, OpenSpec's `Deltas`) reaches Cursor intact. A document whose structure isn't recognised at all is embedded **verbatim** instead of being reduced to placeholders. Only reviewer-facing commentary (`Effort / risks`, `Open questions`, `Alternatives considered`) is deliberately left behind. + +**Where the task file goes — derived, not configured.** Point the command at a spec and the task file is written **next to it**. `PRPs/018-wizard.md` produces `PRPs/-018-wizard.md`, beside its siblings. A spec already says where it belongs, so there is nothing to set up: + +``` +/cursor:from-plan PRPs/018-wizard.md # → PRPs/-018-wizard.md +/cursor:from-plan specs/checkout.md # → specs/-checkout.md +``` + +`tasks/` is the fallback for Claude's own plan-mode files, which have no project location to inherit. Override with `--out-dir `, `CURSOR_PLUGIN_CC_TASKS_DIR`, or a per-repo default via `/cursor:setup --tasks-dir ` (reset with `--no-tasks-dir`). Full precedence: `--out-dir` → the spec's own directory → env var → repo config → `tasks/`. + +**Multi-repo works out of the box.** Nothing here assumes the spec and the code share a repository. Keep your PRPs centralised in one monorepo and run the command from whichever service repo you're changing: + +``` +cd ~/work/breviabook-api +/cursor:from-plan --delegate ~/work/breviabook/PRPs/018-wizard.md +``` + +The task file joins 001–017 in the monorepo — the API repo gets no stray `PRPs/` or `tasks/` folder — and Cursor receives an **absolute path** to it, since `@path` cannot reach outside the repo it runs in. Code changes still land in the repo you invoked from. + +**Or skip the task file entirely.** When your spec already _is_ the task, `--in-place` delegates the source document as written and creates nothing: + +``` +/cursor:from-plan --in-place --delegate PRPs/dark-mode.md +``` + +Same effect as `/cursor:delegate "Implement @PRPs/dark-mode.md"`, but it still resolves the plan by name fragment and prints the title first. Works across repos too. This is the closest thing to "plan in Claude, execute in Cursor" in one session: Claude does the thinking, Cursor does the typing, and the task file is a durable contract between the two. @@ -217,16 +245,16 @@ By default it picks the target automatically: a dirty working tree is reviewed a **Wait or background?** If you don't pass `--wait` or `--background`, the command first estimates the diff size (`git status` / `git diff --shortstat`) and asks once whether to wait for the result or run it in the background — recommending background for anything beyond a tiny 1–2 file change, since a multi-file review can take a while. Pass `--wait` or `--background` explicitly to skip the question. -| Flag | Default | Effect | -| ------------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------- | -| `--base ` | auto | Review the branch diff `...HEAD` (merge-base) instead of the working tree. | -| `--scope auto\|working-tree\|branch` | `auto` | Force the target. `working-tree` = uncommitted changes; `branch` = vs the detected default branch. | -| `--adversarial` | off | Challenge the design and assumptions, not just implementation defects. Prefer the dedicated [`/cursor:adversarial-review`](#cursoradversarial-review-flags-focus) command; this flag is kept for backward compatibility. | -| `--model ` | `auto` | Same aliases as `/cursor:delegate`. Use `gpt`/`opus`/`gemini` for a deeper review. | -| `--background` | off | Detach; returns a job id immediately. Read it later with `/cursor:result`. | -| `--wait` | on | Block until the review finishes (default unless `--background`). | -| `--timeout ` | `1800` | Kill the review if it exceeds this. | -| `--no-git-check` | off | Allow running outside a git repo (rarely useful — there is no diff to review). | +| Flag | Default | Effect | +| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--base ` | auto | Review the branch diff `...HEAD` (merge-base) instead of the working tree. | +| `--scope auto\|working-tree\|branch` | `auto` | Force the target. `working-tree` = uncommitted changes; `branch` = vs the detected default branch. | +| `--adversarial` | off | Challenge the design and assumptions, not just implementation defects. Prefer the dedicated [`/cursor:adversarial-review`](#cursoradversarial-review-flags-focus) command; this flag is kept for backward compatibility. | +| `--model ` | `auto` | Same aliases as `/cursor:delegate`. Use `gpt`/`opus`/`gemini` for a deeper review. | +| `--background` | off | Detach; returns a job id immediately. Read it later with `/cursor:result`. | +| `--wait` | on | Block until the review finishes (default unless `--background`). | +| `--timeout ` | `1800` | Kill the review if it exceeds this. | +| `--no-git-check` | off | Allow running outside a git repo (rarely useful — there is no diff to review). | Examples: @@ -343,10 +371,10 @@ Review runs also emit a machine-readable verdict: the review prompt asks for a t 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). +- **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. +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 @@ -442,12 +470,12 @@ The task file stays in `tasks/` as a durable record — the contract between pla ## Configuration -| Env var | Purpose | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `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: 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`. | +| Env var | Purpose | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `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: 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/from-plan.md b/plugins/cursor/commands/from-plan.md index 82039c2..058cf1a 100644 --- a/plugins/cursor/commands/from-plan.md +++ b/plugins/cursor/commands/from-plan.md @@ -1,6 +1,6 @@ --- -description: Convert a Claude Code plan file into a tasks/.md and optionally hand it off to Cursor. -argument-hint: '[plan-name-fragment] [--delegate] [--model ] [--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 ] [--in-place] [--model ] [--background] [--list]' allowed-tools: Bash(node:*) --- @@ -8,7 +8,21 @@ allowed-tools: Bash(node:*) Render the output verbatim. The command has two modes: -- **Preview + hand back** (default): it writes `tasks/.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 ` 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. diff --git a/plugins/cursor/commands/setup.md b/plugins/cursor/commands/setup.md index e448f1f..ff9fbc2 100644 --- a/plugins/cursor/commands/setup.md +++ b/plugins/cursor/commands/setup.md @@ -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 ]' allowed-tools: Bash(node:*) --- @@ -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 ` 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. diff --git a/plugins/cursor/scripts/from-plan.mjs b/plugins/cursor/scripts/from-plan.mjs index 5d606db..f146b80 100644 --- a/plugins/cursor/scripts/from-plan.mjs +++ b/plugins/cursor/scripts/from-plan.mjs @@ -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/`. +// /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 @`. +// +// 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 @@ -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 = [ @@ -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; @@ -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, @@ -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 ` + * 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 `) + * 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/-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'); @@ -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. @@ -144,23 +246,57 @@ 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', @@ -168,8 +304,8 @@ export async function main(rawArgv) { 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); @@ -177,7 +313,7 @@ export async function main(rawArgv) { 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); diff --git a/plugins/cursor/scripts/lib/config.mjs b/plugins/cursor/scripts/lib/config.mjs index ba0f5cb..f722164 100644 --- a/plugins/cursor/scripts/lib/config.mjs +++ b/plugins/cursor/scripts/lib/config.mjs @@ -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 @@ -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 { @@ -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')); diff --git a/plugins/cursor/scripts/lib/plan.mjs b/plugins/cursor/scripts/lib/plan.mjs index 561d789..4e56fc9 100644 --- a/plugins/cursor/scripts/lib/plan.mjs +++ b/plugins/cursor/scripts/lib/plan.mjs @@ -19,7 +19,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; export const PLANS_DIR = join(homedir(), '.claude', 'plans'); @@ -41,6 +41,7 @@ function isFile(p) { * @property {string} title First `# ` heading text. May be empty. * @property {string} slug kebab-case slug derived from title (or file stem). * @property {Object} sections Section body by lowercased heading key. + * @property {Object} [headings] Lowercased key → original heading text. * @property {string} raw Full file contents. */ @@ -99,17 +100,38 @@ export function resolvePlanPath(ref, plansDir = PLANS_DIR) { return match?.path; } +/** + * True when a resolved plan path is one of Claude's own plan-mode files rather + * than a spec that lives in the user's project. + * + * The distinction drives where a generated task file goes: a project spec knows + * its own home (write the task beside it — that works across repos and needs no + * configuration), while a plan-mode file has no project location to inherit. + * + * @param {string} planPath + * @param {string} [plansDir] + * @returns {boolean} + */ +export function isPlanModeFile(planPath, plansDir = PLANS_DIR) { + return resolve(dirname(planPath)) === resolve(plansDir); +} + /** * Split a plan markdown into (heading → body) pairs keyed by lowercased heading. * Only `## ` headings are split — deeper headings stay inside the section body. * * @param {string} content - * @returns {{ title: string, sections: Object }} + * @returns {{ title: string, sections: Object, headings: Object }} */ export function splitSections(content) { const lines = content.split('\n'); /** @type {Object} */ const sections = {}; + // Lowercased key → the heading exactly as the author wrote it, so a + // pass-through section keeps its original casing instead of being flattened + // to "## all needed context". + /** @type {Object} */ + const headings = {}; let title = ''; let currentKey = ''; /** @type {string[]} */ @@ -139,14 +161,16 @@ export function splitSections(content) { const h2 = /^##\s+(.+?)\s*$/.exec(line); if (h2) { flush(); - currentKey = h2[1].trim().toLowerCase(); + const heading = h2[1].trim(); + currentKey = heading.toLowerCase(); + if (!(currentKey in headings)) headings[currentKey] = heading; continue; } } buffer.push(line); } flush(); - return { title, sections }; + return { title, sections, headings }; } /** @@ -157,9 +181,9 @@ export function splitSections(content) { */ export function parsePlanFile(path) { const raw = readFileSync(path, 'utf8'); - const { title, sections } = splitSections(raw); + const { title, sections, headings } = splitSections(raw); const slug = slugify(title || path.replace(/\.md$/, '').split('/').pop() || 'plan'); - return { path, title, slug, sections, raw }; + return { path, title, slug, sections, headings, raw }; } /** @@ -223,14 +247,38 @@ const SECTION_HINTS = { ], }; +// Plan-mode commentary that is written for the human reviewer, not for the +// implementing agent. These are dropped on purpose and never surface in the +// leftover pass-through below. +const DROPPED_SECTIONS = [ + 'effort / risks', + 'effort/risks', + 'effort', + 'risks', + 'risk', + 'open questions', + 'alternatives considered', + 'notes to reviewer', +]; + /** - * Pick the first matching section for a given intent. + * @param {string} key + * @returns {boolean} + */ +function isDroppedSection(key) { + return DROPPED_SECTIONS.some( + (d) => key === d || key.startsWith(`${d}:`) || key.startsWith(`${d} `), + ); +} + +/** + * Find the section KEY that satisfies a given intent, or '' when none does. * * @param {Object} sections * @param {'context'|'approach'|'files'|'verification'} intent * @returns {string} */ -export function pickSection(sections, intent) { +export function pickSectionKey(sections, intent) { const hints = SECTION_HINTS[intent]; const keys = Object.keys(sections); // Hints are ordered most- to least-specific; iterate them in the OUTER loop @@ -239,13 +287,25 @@ export function pickSection(sections, intent) { for (const hint of hints) { for (const key of keys) { if (key === hint || key.startsWith(`${hint}:`) || key.startsWith(`${hint} `)) { - return sections[key]; + return key; } } } return ''; } +/** + * Pick the first matching section for a given intent. + * + * @param {Object} sections + * @param {'context'|'approach'|'files'|'verification'} intent + * @returns {string} + */ +export function pickSection(sections, intent) { + const key = pickSectionKey(sections, intent); + return key ? sections[key] : ''; +} + /** * Build the Cursor-friendly task file content from a parsed plan. * @@ -253,10 +313,31 @@ export function pickSection(sections, intent) { * @returns {string} */ export function buildTaskContent(plan) { - const context = pickSection(plan.sections, 'context'); - const approach = pickSection(plan.sections, 'approach'); - const files = pickSection(plan.sections, 'files'); - const verification = pickSection(plan.sections, 'verification'); + const contextKey = pickSectionKey(plan.sections, 'context'); + const approachKey = pickSectionKey(plan.sections, 'approach'); + const filesKey = pickSectionKey(plan.sections, 'files'); + const verificationKey = pickSectionKey(plan.sections, 'verification'); + const context = contextKey ? plan.sections[contextKey] : ''; + const approach = approachKey ? plan.sections[approachKey] : ''; + const files = filesKey ? plan.sections[filesKey] : ''; + const verification = verificationKey ? plan.sections[verificationKey] : ''; + + // Anything the four intents did not claim. Previously these were dropped on + // the floor whenever at least one intent matched — which is exactly what + // happens to a PRP (`Goal`, `What`, `All Needed Context` all vanish) or any + // other spec richer than Claude's plan-mode shape. Carrying them through + // verbatim keeps the conversion lossless in every format. + // A spec that states its own goal (PRP's `## Goal`) should fill the Goal slot + // rather than be echoed under the leftovers while the slot repeats the title. + const goalKey = 'goal' in (plan.sections ?? {}) ? 'goal' : ''; + const goal = goalKey ? String(plan.sections[goalKey]).trim() : ''; + + const claimed = new Set( + [goalKey, contextKey, approachKey, filesKey, verificationKey].filter(Boolean), + ); + const leftovers = Object.keys(plan.sections ?? {}).filter( + (key) => !claimed.has(key) && !isDroppedSection(key) && String(plan.sections[key] ?? '').trim(), + ); const lines = []; lines.push(`# ${plan.title || 'Delegated task'}`); @@ -295,7 +376,7 @@ export function buildTaskContent(plan) { lines.push('## Goal'); lines.push(''); - lines.push(plan.title ? plan.title : '(see Context below)'); + lines.push(goal || (plan.title ? plan.title : '(see Context below)')); lines.push(''); lines.push('## Repo context'); lines.push(''); @@ -320,10 +401,35 @@ export function buildTaskContent(plan) { '- Manual spot-check of the changed behaviour.', ); lines.push(''); + pushLeftovers(lines, plan, leftovers); pushConstraints(lines); return lines.join('\n'); } +/** + * Append every section the four intents did not claim, under one wrapper + * heading, with the author's original casing preserved. + * + * @param {string[]} lines + * @param {ParsedPlan} plan + * @param {string[]} leftovers Lowercased section keys, in document order. + */ +function pushLeftovers(lines, plan, leftovers) { + if (leftovers.length === 0) return; + lines.push('## Additional specification context'); + lines.push(''); + lines.push( + '_Carried over verbatim from the source plan — these sections are part of the spec, not commentary._', + ); + lines.push(''); + for (const key of leftovers) { + lines.push(`### ${plan.headings?.[key] ?? key}`); + lines.push(''); + lines.push(String(plan.sections[key]).trim()); + lines.push(''); + } +} + /** * @param {string[]} lines */ diff --git a/plugins/cursor/scripts/setup.mjs b/plugins/cursor/scripts/setup.mjs index 71e2681..8c38e93 100644 --- a/plugins/cursor/scripts/setup.mjs +++ b/plugins/cursor/scripts/setup.mjs @@ -93,6 +93,20 @@ async function gatherDoctor() { }, ]); + const envTasksDir = process.env.CURSOR_PLUGIN_CC_TASKS_DIR; + const cfgTasksDir = getConfig(root).tasksDir; + checks.push([ + 'from-plan output directory', + { + ok: true, + detail: envTasksDir?.trim() + ? `${envTasksDir.trim()} (from CURSOR_PLUGIN_CC_TASKS_DIR)` + : cfgTasksDir + ? `${cfgTasksDir} (repo config)` + : 'tasks/ (default — change with `--tasks-dir `)', + }, + ]); + const mcps = bin ? await listConfiguredMcps() : []; // The CURSOR_API_KEY check is already `ok:true` whether or not the key is @@ -122,6 +136,34 @@ async function toggleReviewGate(enable) { return 0; } +/** + * Persist (or clear) the directory `/cursor:from-plan` writes task files into. + * `--tasks-dir` with no value, or an explicit `--no-tasks-dir`, resets it to + * the built-in `tasks/`. + * + * @param {unknown} raw + * @returns {Promise} + */ +async function setTasksDir(raw) { + const root = await repoRoot(process.cwd()); + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value || raw === false || raw === true) { + setConfigValue(root, 'tasksDir', null); + process.stdout.write( + 'Task output directory **reset** to the default `tasks/` for this repository.\n', + ); + return 0; + } + setConfigValue(root, 'tasksDir', value); + process.stdout.write( + `Task output directory set to \`${value}\` for this repository.\n\n` + + '`/cursor:from-plan` will write generated task files there instead of `tasks/`. ' + + 'Override per run with `--out-dir `, or skip the task file entirely with `--in-place`. ' + + 'Reset with `/cursor:setup --no-tasks-dir`.\n', + ); + return 0; +} + async function doctor(asJson = false) { const { bin, checks, mcps, allOk } = await gatherDoctor(); @@ -234,6 +276,8 @@ export async function main(rawArgv) { 'enable-review-gate', 'disable-review-gate', ]); + const tasksDirFlag = flags['tasks-dir'] ?? flags['tasksDir']; + if (tasksDirFlag !== undefined) return setTasksDir(tasksDirFlag); 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 diff --git a/plugins/cursor/tests/config.test.mjs b/plugins/cursor/tests/config.test.mjs index ac15fb5..15a565f 100644 --- a/plugins/cursor/tests/config.test.mjs +++ b/plugins/cursor/tests/config.test.mjs @@ -19,8 +19,17 @@ describe('config', () => { tmp.cleanup(); }); - it('defaults to stopReviewGate: false', () => { - expect(getConfig(repoA)).toEqual({ stopReviewGate: false }); + it('defaults to stopReviewGate: false and no custom tasks dir', () => { + expect(getConfig(repoA)).toEqual({ stopReviewGate: false, tasksDir: null }); + }); + + it('persists a per-repo tasks dir independently of the review gate', () => { + setConfigValue(repoA, 'tasksDir', 'PRPs'); + expect(getConfig(repoA).tasksDir).toBe('PRPs'); + expect(getConfig(repoA).stopReviewGate).toBe(false); + expect(getConfig(repoB).tasksDir).toBeNull(); + setConfigValue(repoA, 'tasksDir', null); + expect(getConfig(repoA).tasksDir).toBeNull(); }); it('persists a toggled value per repo', () => { diff --git a/plugins/cursor/tests/from-plan-outdir.test.mjs b/plugins/cursor/tests/from-plan-outdir.test.mjs new file mode 100644 index 0000000..3ce7963 --- /dev/null +++ b/plugins/cursor/tests/from-plan-outdir.test.mjs @@ -0,0 +1,234 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_TASKS_DIR, main, referenceFor, resolveTasksDir } from '../scripts/from-plan.mjs'; +import { makeTempHome } from './helpers.mjs'; + +const PRP = `# PRP: Dark Mode Toggle + +## Why + +- Night-shift users report eye strain. + +## All Needed Context + +- CRITICAL: SSR flashes light theme unless set in the inline head script + +## Implementation Blueprint + +Create \`src/theme/ThemeProvider.tsx\`. + +## Validation Loop + +\`npm run lint && npm test\` +`; + +describe('resolveTasksDir', () => { + let tmp; + const prevEnv = process.env.CURSOR_PLUGIN_CC_TASKS_DIR; + const prevHome = process.env.CURSOR_PLUGIN_CC_HOME; + + beforeEach(() => { + tmp = makeTempHome(); + delete process.env.CURSOR_PLUGIN_CC_TASKS_DIR; + // Isolate the per-repo config store so a developer's real config cannot + // leak into these assertions. + process.env.CURSOR_PLUGIN_CC_HOME = join(tmp.dir, 'state'); + }); + + afterEach(() => { + if (prevEnv === undefined) delete process.env.CURSOR_PLUGIN_CC_TASKS_DIR; + else process.env.CURSOR_PLUGIN_CC_TASKS_DIR = prevEnv; + if (prevHome === undefined) delete process.env.CURSOR_PLUGIN_CC_HOME; + else process.env.CURSOR_PLUGIN_CC_HOME = prevHome; + tmp.cleanup(); + }); + + it('defaults to tasks/ under the repo root when there is nothing to derive from', () => { + const { dir, source } = resolveTasksDir(tmp.dir, undefined); + expect(dir).toBe(join(tmp.dir, DEFAULT_TASKS_DIR)); + expect(source).toBe('default'); + }); + + it("derives the destination from the source spec's own directory", () => { + const spec = join(tmp.dir, 'PRPs', '018-wizard.md'); + const { dir, source } = resolveTasksDir(tmp.dir, undefined, spec); + expect(dir).toBe(join(tmp.dir, 'PRPs')); + expect(source).toBe('spec'); + }); + + it('derives it even when the spec lives outside the repo entirely', () => { + // The multi-repo case: PRPs centralised in a sibling monorepo while the + // code being changed lives here. The task file must land beside its + // siblings, not in a new folder inside this repo. + const spec = join(tmp.dir, 'elsewhere', 'mono', 'PRPs', '018-wizard.md'); + const { dir, source } = resolveTasksDir(join(tmp.dir, 'api'), undefined, spec); + expect(dir).toBe(join(tmp.dir, 'elsewhere', 'mono', 'PRPs')); + expect(source).toBe('spec'); + }); + + it('lets an explicit --out-dir override the spec directory', () => { + const spec = join(tmp.dir, 'PRPs', '018-wizard.md'); + const { dir, source } = resolveTasksDir(tmp.dir, 'tasks', spec); + expect(dir).toBe(join(tmp.dir, 'tasks')); + expect(source).toBe('flag'); + }); + + it('prefers the spec directory over env and config', () => { + process.env.CURSOR_PLUGIN_CC_TASKS_DIR = 'specs'; + const spec = join(tmp.dir, 'PRPs', '018-wizard.md'); + expect(resolveTasksDir(tmp.dir, undefined, spec).source).toBe('spec'); + }); + + it('honours the --out-dir flag, resolved against the repo root', () => { + const { dir, source } = resolveTasksDir(tmp.dir, 'PRPs'); + expect(dir).toBe(join(tmp.dir, 'PRPs')); + expect(source).toBe('flag'); + }); + + it('accepts an absolute --out-dir verbatim', () => { + const abs = join(tmp.dir, 'elsewhere', 'specs'); + expect(resolveTasksDir(tmp.dir, abs).dir).toBe(abs); + }); + + it('falls back to the env var when no flag is given', () => { + process.env.CURSOR_PLUGIN_CC_TASKS_DIR = 'specs'; + const { dir, source } = resolveTasksDir(tmp.dir, undefined); + expect(dir).toBe(join(tmp.dir, 'specs')); + expect(source).toBe('env'); + }); + + it('lets the flag win over the env var', () => { + process.env.CURSOR_PLUGIN_CC_TASKS_DIR = 'specs'; + expect(resolveTasksDir(tmp.dir, 'PRPs').dir).toBe(join(tmp.dir, 'PRPs')); + }); + + it('ignores a blank env var instead of writing to the repo root', () => { + process.env.CURSOR_PLUGIN_CC_TASKS_DIR = ' '; + expect(resolveTasksDir(tmp.dir, undefined).source).toBe('default'); + }); +}); + +describe('from-plan writes into the configured directory', () => { + let tmp; + let repo; + const prevCwd = process.cwd(); + const prevEnv = process.env.CURSOR_PLUGIN_CC_TASKS_DIR; + const prevHome = process.env.CURSOR_PLUGIN_CC_HOME; + let out; + + beforeEach(() => { + tmp = makeTempHome(); + repo = join(tmp.dir, 'repo'); + mkdirSync(join(repo, 'PRPs'), { recursive: true }); + writeFileSync(join(repo, 'PRPs', 'dark-mode.md'), PRP, 'utf8'); + delete process.env.CURSOR_PLUGIN_CC_TASKS_DIR; + process.env.CURSOR_PLUGIN_CC_HOME = join(tmp.dir, 'state'); + process.chdir(repo); + out = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out += String(chunk); + return true; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.chdir(prevCwd); + if (prevEnv === undefined) delete process.env.CURSOR_PLUGIN_CC_TASKS_DIR; + else process.env.CURSOR_PLUGIN_CC_TASKS_DIR = prevEnv; + if (prevHome === undefined) delete process.env.CURSOR_PLUGIN_CC_HOME; + else process.env.CURSOR_PLUGIN_CC_HOME = prevHome; + tmp.cleanup(); + }); + + it('--out-dir PRPs keeps the task file beside the spec and creates no tasks/', async () => { + const code = await main(['--out-dir', 'PRPs', 'PRPs/dark-mode.md']); + expect(code).toBe(0); + expect(existsSync(join(repo, 'tasks'))).toBe(false); + const written = readdirSync(join(repo, 'PRPs')).filter((f) => f !== 'dark-mode.md'); + expect(written).toHaveLength(1); + // The gotcha that used to be dropped now reaches the task file. + const body = readFileSync(join(repo, 'PRPs', written[0]), 'utf8'); + expect(body).toContain('CRITICAL: SSR flashes light theme'); + expect(out).toContain('from --out-dir'); + }); + + it('--in-place writes nothing and delegates the spec path itself', async () => { + const code = await main(['--in-place', 'PRPs/dark-mode.md']); + expect(code).toBe(0); + expect(existsSync(join(repo, 'tasks'))).toBe(false); + expect(readdirSync(join(repo, 'PRPs'))).toEqual(['dark-mode.md']); + expect(out).toContain('no task file written'); + expect(out).toContain('@PRPs/dark-mode.md'); + }); + + it('writes beside the source spec by default, with no configuration at all', async () => { + const code = await main(['PRPs/dark-mode.md']); + expect(code).toBe(0); + expect(existsSync(join(repo, 'tasks'))).toBe(false); + expect(readdirSync(join(repo, 'PRPs'))).toHaveLength(2); + expect(out).toContain("from the source spec's own directory"); + }); + + describe('spec in a sibling repo (central spec directory)', () => { + let mono; + let spec; + + beforeEach(() => { + mono = join(tmp.dir, 'mono'); + mkdirSync(join(mono, 'PRPs'), { recursive: true }); + spec = join(mono, 'PRPs', '018-wizard.md'); + writeFileSync(spec, PRP, 'utf8'); + }); + + it('lands the task file beside its siblings, not inside the code repo', async () => { + const code = await main([spec]); + expect(code).toBe(0); + // Nothing new in the code repo… + expect(existsSync(join(repo, 'tasks'))).toBe(false); + expect(readdirSync(join(repo, 'PRPs'))).toEqual(['dark-mode.md']); + // …and the task file joined the spec it came from. + expect(readdirSync(join(mono, 'PRPs'))).toHaveLength(2); + }); + + it('hands Cursor an absolute path, since @path cannot leave the repo', async () => { + await main([spec]); + expect(out).toContain(join(mono, 'PRPs')); + expect(out).toContain('lives outside this repository'); + expect(out).not.toMatch(/@\.\./); + }); + + it('--in-place no longer refuses a spec outside the repo', async () => { + const code = await main(['--in-place', spec]); + expect(code).toBe(0); + expect(out).toContain('no task file written'); + expect(out).toContain(spec); + expect(out).toContain('applies changes here'); + // Still wrote nothing anywhere. + expect(readdirSync(join(mono, 'PRPs'))).toEqual(['018-wizard.md']); + expect(existsSync(join(repo, 'tasks'))).toBe(false); + }); + }); +}); + +describe('referenceFor', () => { + it('uses the @path shorthand inside the repo', () => { + const r = referenceFor('/repo', '/repo/PRPs/018.md'); + expect(r).toEqual({ mention: '@PRPs/018.md', display: 'PRPs/018.md', inside: true }); + }); + + it('falls back to the absolute path outside the repo', () => { + const r = referenceFor('/repo', '/mono/PRPs/018.md'); + expect(r).toEqual({ + mention: '/mono/PRPs/018.md', + display: '/mono/PRPs/018.md', + inside: false, + }); + }); + + it('treats a sibling directory sharing a name prefix as outside', () => { + // `/repo-api` must not be mistaken for a child of `/repo`. + expect(referenceFor('/repo', '/repo-api/PRPs/018.md').inside).toBe(false); + }); +}); diff --git a/plugins/cursor/tests/plan.test.mjs b/plugins/cursor/tests/plan.test.mjs index 407fcc1..771d757 100644 --- a/plugins/cursor/tests/plan.test.mjs +++ b/plugins/cursor/tests/plan.test.mjs @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildTaskContent, + isPlanModeFile, listPlans, parsePlanFile, resolvePlanPath, @@ -68,6 +69,26 @@ describe('slugify', () => { }); }); +describe('isPlanModeFile', () => { + const plansDir = '/home/u/.claude/plans'; + + it('recognises a file sitting directly in the plans dir', () => { + expect(isPlanModeFile('/home/u/.claude/plans/brand-new-plan.md', plansDir)).toBe(true); + }); + + it('treats a project spec as not plan-mode', () => { + expect(isPlanModeFile('/work/mono/PRPs/018-wizard.md', plansDir)).toBe(false); + }); + + it('does not match a nested subdirectory of the plans dir', () => { + expect(isPlanModeFile('/home/u/.claude/plans/archive/old.md', plansDir)).toBe(false); + }); + + it('normalises traversal before comparing', () => { + expect(isPlanModeFile('/home/u/.claude/plans/../plans/p.md', plansDir)).toBe(true); + }); +}); + describe('buildTaskContent', () => { it('includes all five task sections, dropping dev commentary', () => { const plan = { @@ -138,6 +159,77 @@ describe('buildTaskContent', () => { expect(out.match(/# Externí spec/g)).toHaveLength(1); }); + it('carries PRP sections that map to no intent instead of dropping them', () => { + // A PRP matches three intents (Why → context, Implementation Blueprint → + // approach, Validation Loop → verification), so the all-or-nothing verbatim + // fallback never fires. Goal / What / All Needed Context must still survive. + const raw = [ + '# PRP: Dark Mode Toggle', + '', + '## Goal', + '', + 'Ship a persisted dark-mode toggle.', + '', + '## Why', + '', + '- Night-shift users report eye strain.', + '', + '## What', + '', + '### Success Criteria', + '- [ ] Toggle persists across reloads', + '', + '## All Needed Context', + '', + '- file: src/theme/tokens.ts', + '- CRITICAL: SSR flashes light theme unless set in the inline head script', + '', + '## Implementation Blueprint', + '', + 'Create `src/theme/ThemeProvider.tsx`.', + '', + '## Validation Loop', + '', + '`npm run lint && npm test`', + '', + ].join('\n'); + const { title, sections, headings } = splitSections(raw); + const out = buildTaskContent({ + path: '/tmp/PRPs/dark-mode.md', + title, + slug: 'prp-dark-mode-toggle', + sections, + headings, + raw, + }); + // Mapped sections keep their structured slots… + expect(out).toContain('Night-shift users report eye strain.'); + expect(out).toContain('Create `src/theme/ThemeProvider.tsx`.'); + expect(out).toContain('npm run lint && npm test'); + // …and the unmapped ones are no longer lost. + expect(out).toContain('## Additional specification context'); + expect(out).toContain('Ship a persisted dark-mode toggle.'); + expect(out).toContain('- [ ] Toggle persists across reloads'); + expect(out).toContain('CRITICAL: SSR flashes light theme'); + // Original heading casing is preserved, not flattened to lowercase. + expect(out).toContain('### All Needed Context'); + }); + + it('still drops reviewer-only commentary from the leftovers', () => { + const { title, sections, headings } = splitSections(SAMPLE); + const out = buildTaskContent({ + path: '/tmp/plan.md', + title, + slug: 'refactor-x', + sections, + headings, + raw: SAMPLE, + }); + expect(out).not.toContain('Effort / risks'); + expect(out).not.toContain('hand-rolled arg parser'); + expect(out).not.toContain('## Additional specification context'); + }); + it('maps common spec-driven headings onto the task sections', () => { const raw = [ '# Spec-shaped plan',