diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json
new file mode 100644
index 00000000..55a86596
--- /dev/null
+++ b/.cursor-plugin/marketplace.json
@@ -0,0 +1,17 @@
+{
+ "name": "skillopt",
+ "owner": {
+ "name": "Yifan Yang",
+ "email": "yifanyang@microsoft.com"
+ },
+ "metadata": {
+ "description": "Official SkillOpt plugins for usage-driven, validation-gated agent improvement."
+ },
+ "plugins": [
+ {
+ "name": "skillopt-sleep",
+ "source": "plugins/cursor",
+ "description": "Review recent Cursor sessions, replay recurring work, and stage validation-gated improvements to a Cursor skill for explicit adoption."
+ }
+ ]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b06b962..c21ad963 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ All notable changes to SkillOpt are documented here. This project adheres to
## [Unreleased]
### Added
+- Native SkillOpt-Sleep support for Cursor, including a local plugin command
+ and skill, Cursor transcript harvesting, and an optional Cursor Agent CLI
+ backend. Cursor tool-aware replay remains disabled pending live permission-
+ boundary validation.
- **Cursor Agent research target harness** (`cursor_exec`) for running
supported benchmark rollouts through an installed, authenticated
`cursor-agent`, with sandboxed workspaces, structured trace capture, and
diff --git a/docs/guide/installation.md b/docs/guide/installation.md
index 1b24a2d8..e35d555d 100644
--- a/docs/guide/installation.md
+++ b/docs/guide/installation.md
@@ -26,9 +26,10 @@ checkout for those files.
!!! important "PyPI versus `main`"
These docs track the latest `main`. The current PyPI release is `0.2.0`.
The generic research `openai_compatible` backend, SkillOpt-Sleep handoff,
- Sleep support for non-Azure OpenAI-compatible endpoints, and the Sleep
- `--preferences` flag landed after that release and require a source install
- from `main` until the next release.
+ Sleep support for non-Azure OpenAI-compatible endpoints, the Sleep
+ `--preferences` flag, and Cursor source/backend/plugin support landed after
+ that release and require a source install from `main` until the next
+ release.
### Source checkout
@@ -128,6 +129,13 @@ Anthropic API client. Install and authenticate `claude`, and set
`CLAUDE_CLI_BIN` only if the executable is not available as `claude` on
`PATH`. `ANTHROPIC_API_KEY` is one authentication option the CLI may consume.
+The SkillOpt-Sleep `cursor` backend similarly requires a separately installed
+and authenticated `cursor-agent`; harvesting with `--source cursor` alone does
+not. Set `SKILLOPT_SLEEP_CURSOR_PATH` when the executable is not on `PATH`, and
+`SKILLOPT_SLEEP_CURSOR_MODEL` to override its model. Cursor plugin installation
+and the explicit project skill target are documented in the
+[Cursor integration guide](https://github.com/microsoft/SkillOpt/blob/main/plugins/cursor/README.md).
+
OpenAI-compatible servers have three distinct entry points:
1. The research engine's generic `openai_compatible` backend uses
diff --git a/docs/guideline.html b/docs/guideline.html
index 2973dec7..801ccaf6 100644
--- a/docs/guideline.html
+++ b/docs/guideline.html
@@ -295,9 +295,10 @@
Install
Release boundary
This guide tracks main. PyPI currently serves 0.2.0; the
generic research openai_compatible backend, Sleep handoff,
- SkillOpt-Sleep support for non-Azure OpenAI-compatible endpoints, and the
- Sleep --preferences flag require a source install from
- main until the next release.
+ SkillOpt-Sleep support for non-Azure OpenAI-compatible endpoints, the
+ Sleep --preferences flag, and Cursor source/backend/plugin
+ support require a source install from main until the next
+ release.
See the installation guide
for platform notes and dependency boundaries.
@@ -451,8 +452,25 @@ SkillOpt-Sleep: safe first run
--project scopes collection but does not automatically
choose a project's skill file. Use --target-skill-path when
you intend to evolve a particular SKILL.md. Transcript source
- (claude, codex, or auto) and replay
- backend are independent settings.
+ (claude, codex, cursor, or
+ auto) and replay backend are independent settings. The existing
+ auto precedence remains Codex then Claude; select Cursor
+ explicitly with --source cursor.
+ Cursor transcripts default to
+ ~/.cursor/projects/<workspace>/agent-transcripts; override
+ that home with --cursor-home. Model-driven replay requires an
+ installed, authenticated cursor-agent and
+ --backend cursor; use --cursor-path when the CLI is
+ not on PATH. Target
+ .cursor/skills/skillopt-sleep-learned/SKILL.md explicitly so
+ adoption updates a project skill rather than the plugin's workflow skill.
+ The Cursor backend inserts that skill text into prompts; it does not
+ invoke the file as a native skill. Ordinary calls run in read-only Ask mode
+ in an empty temporary workspace and cannot inspect files under
+ --project. Cursor tasks containing a tool_called
+ check fail before Agent mode starts; use another backend for those tasks.
+ This validates textual guidance, not end-to-end repository, browser,
+ service, or filesystem workflows.
For subscription-based workflows that should not launch an API or model
subprocess, use --backend handoff and follow the generated
prompt/answer loop. Read the
@@ -468,6 +486,7 @@
Agent integrations
| Claude Code | Shared-engine plugin and handoff command | README |
| Codex | Shared-engine skill shell | README |
+ | Cursor | Native command and skill, local transcript source, and Cursor Agent backend | README |
| GitHub Copilot | Shared-engine Sleep MCP plus a separate research MCP | README |
| Devin | Shared-engine MCP with Devin transcript conversion | README |
| OpenClaw | Independent community/reference adaptation; review locally before use | README |
@@ -491,6 +510,7 @@ Advanced Sleep controls
dream_rollouts | 1 | Single rollout by default; values above 1 enable experimental contrastive replay. |
dream_factor | 0 | Synthetic task variants are off by default. |
recall_k | 0 | Historical associative recall is off by default. |
+ replay_mode | mock | Reporting label for prompt replay; fresh-worktree replay is not implemented. |
@@ -499,6 +519,13 @@ Advanced Sleep controls
reward/budget controls as advanced features that require task-specific
validation. The reported experiments and their exact settings are in
RESULTS.md.
+ The managed scheduler persists only project, backend, time, and optional
+ auto-adopt. For Cursor schedules, put transcript_source,
+ cursor_home, cursor_path, model, and
+ target_skill_path in
+ ~/.skillopt-sleep/config.json. Use an absolute Cursor CLI path
+ and verify authentication for the scheduled account because schedulers may
+ run with a minimal environment.
@@ -511,6 +538,13 @@ Data, privacy, and adoption safety
is not a guarantee that every outbound model prompt is free of sensitive
content. In particular, do not treat raw coding-agent transcripts as
pre-sanitized.
+ The Cursor source excludes tool arguments and outputs, retaining only
+ user/assistant text, explicit turn errors, and tool names. The Cursor
+ backend still sends transcript-derived prompts through
+ cursor-agent to Cursor's selected model provider.
+ A real-backend dry-run still performs provider calls; it
+ suppresses staging rather than spend. Session and task limits are not hard
+ provider-call, token, time, or monetary budgets.
Updates are staged for review by default. Use
--auto-adopt only when you have an independent rollback and
validation process.
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index 57269f48..69fa943c 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -3,8 +3,9 @@
> **Version note.** This reference tracks `main`. PyPI 0.2.0 does not yet
> include the generic research `openai_compatible` backend, Sleep handoff,
> Sleep support for non-Azure OpenAI-compatible endpoints, the Sleep
-> `--preferences` flag, or the research `cursor_exec` target harness; use a
-> source install from `main` for those features until the next release.
+> `--preferences` flag, the research `cursor_exec` target harness, or Cursor
+> source/backend/plugin support; use a source install from `main` for those
+> features until the next release.
## Training
@@ -125,11 +126,13 @@ Actions are `run`, `dry-run`, `status`, `adopt`, `harvest`, `schedule`, and
| Argument | Description |
|---|---|
-| `--project PATH` | Project to evolve (default: current directory) |
+| `--project PATH` | Project used for transcript scope, targets, state, and staging (default: current directory) |
| `--scope invoked\|all` | Harvest this project or all projects |
-| `--source claude\|codex\|auto` | Transcript source |
-| `--backend mock\|claude\|codex\|copilot\|handoff\|azure_openai` | Replay/optimizer backend |
+| `--source claude\|codex\|cursor\|auto` | Transcript source; `auto` keeps Codex-then-Claude precedence and does not select Cursor |
+| `--backend mock\|claude\|codex\|copilot\|cursor\|handoff\|azure_openai` | Replay/optimizer backend |
| `--model NAME` | Backend-specific model override |
+| `--cursor-home PATH` | Override `~/.cursor` for Cursor transcript harvesting |
+| `--cursor-path PATH` | Path to the installed Cursor Agent CLI |
| `--preferences TEXT` | House rules supplied to reflection |
| `--lookback-hours N` | Initial transcript lookback; `0` scans all history |
| `--max-sessions N` / `--max-tasks N` | Bound the harvested workload |
@@ -139,6 +142,82 @@ Actions are `run`, `dry-run`, `status`, `adopt`, `harvest`, `schedule`, and
| `--progress` / `--json` | Progress or machine-readable output |
| `--auto-adopt` | Apply an accepted staged proposal automatically |
+### Cursor source and backend
+
+`--source cursor` reads local Cursor JSONL transcripts from
+`~/.cursor/projects//agent-transcripts/*/*.jsonl`. Invoked scope uses
+Cursor's recorded workspace path, including when `--project` is a nested
+directory, and falls back to the sanitized storage name when metadata is not
+available. `--scope all` scans every workspace below `cursor_home`. The
+harvester retains user/assistant text, explicit turn errors, and tool names,
+while excluding tool arguments, tool outputs, and non-message records. It
+redacts known secret patterns and filters SkillOpt-generated replay sessions,
+but redaction is not a guarantee that outbound prompts contain no sensitive
+data.
+
+`--backend cursor` launches an installed, authenticated `cursor-agent`, sends
+prompts over stdin, and parses its JSON result. SkillOpt reads the target skill
+and includes its text in replay prompts; it does not invoke that file as a native
+Cursor skill. Ordinary mining, replay, judging, and reflection calls use
+read-only Ask mode in a new empty temporary workspace. Project file reads, file
+writes, and MCP tools are denied. `--project` does not change that execution
+workspace.
+
+Cursor tool-aware replay is temporarily disabled pending live Cursor
+permission-boundary validation. A task with a `tool_called` check fails nonzero
+before Agent mode starts and does not stage, adopt, cache, persist state, or
+advance the harvest checkpoint. Use another backend for such tasks. The current
+Cursor backend therefore does not provide end-to-end validation for skills that
+need repository inspection, real CLIs, browsers, running services, or file
+changes.
+
+There is no implemented fresh-worktree Cursor replay. If a report says
+`replay: mock`, that is the prompt-replay label and does not mean the mock model
+backend was selected. Both `run` and `dry-run` perform real-backend provider
+calls; `dry-run` suppresses staging, adoption, and persisted state changes, not
+spend. Session and task limits do not impose hard provider-call, token, time, or
+monetary budgets.
+Cursor and its selected model provider can receive the prompt content.
+
+Cursor-specific settings are available through the CLI, config, and environment:
+
+| Purpose | CLI | `~/.skillopt-sleep/config.json` | Environment |
+|---|---|---|---|
+| Transcript home | `--cursor-home PATH` | `"cursor_home": "/path/to/.cursor"` | none |
+| Agent executable | `--cursor-path PATH` | `"cursor_path": "/path/to/cursor-agent"` | `SKILLOPT_SLEEP_CURSOR_PATH` |
+| Model | `--model NAME` | `"model": "NAME"` | `SKILLOPT_SLEEP_CURSOR_MODEL` |
+
+Use `cursor-agent --list-models` to inspect model identifiers available to the
+authenticated account. When cost depends on a model variant, confirm the billed
+variant in Cursor's usage reporting rather than relying only on its display
+name.
+
+Target the learned project skill explicitly so accepted updates are visible to
+Cursor without modifying the plugin's own `skillopt-sleep` workflow skill:
+
+```bash
+skillopt-sleep run --project "$(pwd)" \
+ --source cursor --backend cursor \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \
+ --max-sessions 5 --max-tasks 3 --progress
+```
+
+The first harvest uses a 72-hour lookback unless `--lookback-hours` is set. A
+value of `0` considers all available history while still respecting
+`--max-sessions`. A stateful `run`, including a run that mines no tasks, records
+a new harvest checkpoint; subsequent runs use that checkpoint rather than the
+initial lookback. Use `harvest` or `dry-run` to verify counts before the first
+stateful run.
+
+The managed `schedule` command persists the project, backend, time, and optional
+auto-adopt setting only. It does not copy source, Cursor paths, model, or target
+skill flags into the scheduled command. Put `transcript_source`, `cursor_home`,
+`cursor_path`, `model`, and `target_skill_path` in the user config before
+scheduling Cursor. Keep `target_skill_path` project-relative as
+`.cursor/skills/skillopt-sleep-learned/SKILL.md`, prefer an absolute
+`cursor_path`, and verify authentication for the scheduled account because cron
+and Task Scheduler may have a minimal environment.
+
Backend-specific setup for compatible endpoints is documented in
[OpenAI-compatible endpoints for SkillOpt-Sleep](../sleep/openai-compatible-endpoints.md).
diff --git a/docs/sleep/README.md b/docs/sleep/README.md
index 2e36eb16..6e4d7856 100644
--- a/docs/sleep/README.md
+++ b/docs/sleep/README.md
@@ -17,7 +17,7 @@ normal agent requests.
One "night":
```
-harvest Claude Code / Codex transcripts → mine recurring tasks → replay offline
+harvest Claude Code / Codex / Cursor transcripts → mine recurring tasks → replay in isolated model calls
→ consolidate (reflect → bounded edit → GATE on real held-out tasks)
→ stage proposal → (you) adopt
```
@@ -33,6 +33,15 @@ experience → long-term competence).
> review your transcript source and provider policy before running on sensitive
> projects. For a reviewable workflow, harvest to a task file, inspect/redact it, mark
> it `"reviewed": true`, and then replay that file with the real backend.
+>
+> The Cursor source reads local user/assistant message text, explicit turn errors,
+> and tool names, but excludes tool arguments, tool outputs, and non-message records.
+> Known secret-shaped strings are redacted as defense in depth. The Cursor backend
+> sends prompts through `cursor-agent`; ordinary calls use read-only Ask mode in an
+> empty temporary workspace with project files denied. Cursor tool-aware replay is
+> temporarily disabled pending live permission-boundary validation.
+> Cursor and the model provider selected by Cursor may therefore receive
+> transcript-derived content.
## How to use it
@@ -48,13 +57,14 @@ skillopt-sleep schedule # install a nightly cron entry for this project
```
> **Version note.** This page tracks `main`. PyPI 0.2.0 provides the base
-> commands above. Sleep handoff, non-Azure OpenAI-compatible endpoints, and
-> `--preferences` landed later and require a source install from `main` until
-> the next release.
+> commands above. Cursor source/backend/plugin support, Sleep handoff, non-Azure
+> OpenAI-compatible endpoints, and `--preferences` landed later and require a
+> source install from `main` until the next release.
The per-agent integrations below still come from the repo; the CLI above is the
-standalone, pip-only way to run a cycle. Claude Code, Codex, Copilot, and Devin wrap
-the shared engine. OpenClaw is a separate reference adaptation and has its own setup.
+standalone, pip-only way to run a cycle. Claude Code, Codex, Cursor, Copilot, and
+Devin wrap the shared engine. OpenClaw is a separate reference adaptation and has
+its own setup.
One engine, thin per-agent shells (see [`plugins/`](https://github.com/microsoft/SkillOpt/tree/main/plugins)):
@@ -62,10 +72,62 @@ One engine, thin per-agent shells (see [`plugins/`](https://github.com/microsoft
|---|---|---|
| **Claude Code** | [`plugins/claude-code`](https://github.com/microsoft/SkillOpt/tree/main/plugins/claude-code) | `/plugin marketplace add ./plugins/claude-code` → `/skillopt-sleep` |
| **Codex** | [`plugins/codex`](https://github.com/microsoft/SkillOpt/tree/main/plugins/codex) | `bash plugins/codex/install.sh` → `skillopt-sleep` skill |
+| **Cursor** | [`plugins/cursor`](https://github.com/microsoft/SkillOpt/tree/main/plugins/cursor) | `bash plugins/cursor/install.sh` → `/skillopt-sleep` |
| **Copilot** | [`plugins/copilot`](https://github.com/microsoft/SkillOpt/tree/main/plugins/copilot) | register `plugins/copilot/mcp_server.py` as an MCP server |
| **Devin** | [`plugins/devin`](https://github.com/microsoft/SkillOpt/tree/main/plugins/devin) | register `plugins/devin/mcp_server.py` as an MCP server |
| **OpenClaw** | [`plugins/openclaw`](https://github.com/microsoft/SkillOpt/tree/main/plugins/openclaw) | adapt the reference wrapper and paths for your installation |
+### Cursor
+
+Cursor transcript harvesting and model execution are independent. Use
+`--source cursor` to read
+`~/.cursor/projects//agent-transcripts/*/*.jsonl`; `--scope invoked`
+uses Cursor's recorded workspace path, with the sanitized storage directory as
+a fallback, while `--scope all` scans every Cursor workspace. Use
+`--cursor-home` for a different Cursor home. `--source auto` keeps its existing
+Codex-then-Claude precedence and does not select Cursor.
+
+`--backend cursor` requires an installed, authenticated `cursor-agent`. If it is
+not on `PATH`, select it with `--cursor-path`, `SKILLOPT_SLEEP_CURSOR_PATH`, or
+the `cursor_path` config key. Select a model with `--model` or
+`SKILLOPT_SLEEP_CURSOR_MODEL`. Point adoption at a project Cursor skill rather
+than at the plugin's workflow skill:
+
+```bash
+skillopt-sleep run --project "$(pwd)" \
+ --source cursor --backend cursor \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \
+ --max-sessions 5 --max-tasks 3 --progress
+```
+
+The target skill is supplied to Cursor as prompt text; it is not invoked as a
+native skill. `--project` selects transcript scope, target files, state, and
+staging, but ordinary Cursor calls cannot inspect that project's files. The
+current backend therefore evaluates textual guidance rather than end-to-end
+repository, CLI, browser, or service workflows.
+
+Cursor tool-aware replay is temporarily disabled pending live Cursor
+permission-boundary validation. If a task contains a `tool_called` check, the
+Cursor backend exits nonzero before starting Agent mode and does not stage,
+adopt, or advance state. Use another backend for those tasks.
+
+The initial harvest window is 72 hours. Set `--lookback-hours N` explicitly when
+older sessions should be considered; `0` scans all history subject to the
+session limit. A stateful `run`, even with no mined tasks, advances the harvest
+checkpoint. Use `harvest` or `dry-run` to inspect counts first. A real-backend
+`dry-run` still incurs provider calls and spend, and session/task limits are not
+hard call, token, time, or monetary budgets.
+
+The managed scheduler records only the project, backend, time, and optional
+auto-adopt setting. It does not preserve Cursor source, home, CLI path, model, or
+target-skill flags. Before `skillopt-sleep schedule --backend cursor`, put
+`transcript_source`, `cursor_home`, `cursor_path`, `model`, and
+`target_skill_path` in `~/.skillopt-sleep/config.json`. The target may remain
+project-relative as `.cursor/skills/skillopt-sleep-learned/SKILL.md`. Use an
+absolute `cursor_path` and verify that the scheduled account is already
+authenticated, because cron and Task Scheduler may run with a minimal
+environment.
+
To use DeepSeek, vLLM, Ollama, or another Chat Completions server, see
**[OpenAI-compatible endpoints](openai-compatible-endpoints.md)**. That guide also
documents the separate HTTPS-only boundary for Azure managed-identity credentials.
diff --git a/plugins/README.md b/plugins/README.md
index 51230220..61d222c9 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -10,13 +10,14 @@ runtime dependency on the paper's `skillopt/` experiment package.
## Available integrations
-Four integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate
+Five integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate
reference adaptation with its own backend and setup assumptions.
| Platform | Folder | Mechanism | Status |
|---|---|---|---|
| **Claude Code** | [`claude-code/`](claude-code) | marketplace plugin, commands, skill, and hooks | installable shared-engine integration |
| **Codex** | [`codex/`](codex) | user-level skill and shared runner | installable shared-engine integration |
+| **Cursor** | [`cursor/`](cursor) | native command and skill, project skill target, and shared runner | installable shared-engine integration |
| **GitHub Copilot** | [`copilot/`](copilot) | MCP server exposing seven `sleep_*` tools | shared-engine MCP integration |
| **Devin** | [`devin/`](devin) | MCP server plus Devin transcript conversion | shared-engine MCP integration |
| **OpenClaw** | [`openclaw/`](openclaw) | custom DeepSeek/Ollama wrapper | independent reference adaptation; review and adapt before use |
@@ -30,6 +31,7 @@ for your workflow.
|---|---|---|
| **Claude Code** | from the repository root, `/plugin marketplace add ./plugins/claude-code`, then `/plugin install skillopt-sleep@skillopt-sleep` | `/skillopt-sleep status` |
| **Codex** | `bash plugins/codex/install.sh` | ask Codex to use the `skillopt-sleep` skill |
+| **Cursor** | `bash plugins/cursor/install.sh` (macOS/Linux) or `powershell -File plugins/cursor/install.ps1` (Windows) | `/skillopt-sleep status` |
| **Copilot** | register `plugins/copilot/mcp_server.py` using its example MCP config | ask Copilot to run `sleep_status` |
| **Devin** | register `plugins/devin/mcp_server.py` using its example MCP config | ask Devin to run `sleep_status` |
| **OpenClaw** | follow and adapt [`openclaw/README.md`](openclaw/README.md) | validate paths, credentials, and tasks locally |
@@ -44,9 +46,9 @@ an importable `skillopt_sleep` module. Install with `uv tool install skillopt` o
`pip install skillopt` when using that fallback.
> **Version note.** This integration reference tracks `main`. PyPI 0.2.0
-> supports the base Sleep CLI, while handoff, Sleep support for non-Azure
-> OpenAI-compatible endpoints, and `--preferences` require a source checkout
-> from `main` until the next release.
+> supports the base Sleep CLI, while Cursor source/backend/plugin support,
+> handoff, Sleep support for non-Azure OpenAI-compatible endpoints, and
+> `--preferences` require a source checkout from `main` until the next release.
## One sleep cycle
@@ -66,6 +68,17 @@ optimization.
data path and no API spend.
- A real backend sends truncated transcript excerpts and derived task content to
the provider selected for mining, replay, judging, and reflection.
+- The Cursor source reads local user/assistant message text, explicit turn
+ errors, and tool names from `~/.cursor/projects/*/agent-transcripts`; it does
+ not retain tool arguments, tool outputs, or other record types. Known
+ secret-shaped strings are redacted, but this is defense in depth rather than
+ a guarantee that outbound prompts are secret-free.
+- The Cursor backend sends prompts through the installed, authenticated
+ `cursor-agent` CLI. Ordinary calls use read-only Ask mode in a new empty
+ temporary workspace with project file access denied. Cursor tasks containing
+ `tool_called` validation fail before Agent mode starts; use another backend for
+ those tasks. Cursor and the model provider selected by Cursor can receive the
+ resulting prompt content.
- Outbound prompts are not currently guaranteed to be free of secrets. Do not
use a third-party provider on sensitive transcripts without reviewing the data
source and the provider's retention policy.
@@ -101,9 +114,11 @@ Common implemented flags include:
| Flag | Default | Purpose |
|---|---|---|
-| `--backend mock\|claude\|codex\|copilot\|handoff\|azure_openai` | `mock` | select who performs model calls |
+| `--backend mock\|claude\|codex\|cursor\|copilot\|handoff\|azure_openai` | `mock` | select who performs model calls |
| `--model NAME` | backend default | select a backend-specific model |
-| `--source claude\|codex\|auto` | `claude` | select the transcript source |
+| `--source claude\|codex\|cursor\|auto` | `claude` | select the transcript source; `auto` retains Codex-then-Claude precedence and does not select Cursor |
+| `--cursor-home PATH` | `~/.cursor` | override the Cursor transcript home |
+| `--cursor-path PATH` | auto-detect `cursor-agent` | select the Cursor Agent CLI executable |
| `--project PATH` | current directory | select the project and invoked harvest scope |
| `--scope invoked\|all` | `invoked` | limit transcript harvesting |
| `--target-skill-path PATH` | managed skill | select a specific `SKILL.md` to stage/adopt |
@@ -119,6 +134,14 @@ The nightly CLI does **not** currently expose `--gate`, `--rollouts-k`,
`--optimizer-model`, `--target-model`, `--budget-tokens`, or `--budget-minutes`.
Do not pass experiment-harness flags to the main CLI.
+For the Cursor backend, `--project` also selects target files, state, and the
+staging location, but it does not make that directory the Cursor Agent execution
+workspace. The target skill is inserted as prompt text rather than invoked as a
+native skill. Real-backend `dry-run` performs the same mining and replay model
+calls while suppressing staging, adoption, and persisted state changes. The
+current Sleep cycle does not implement fresh-worktree replay; a `replay: mock`
+report label describes prompt replay and is independent of `--backend mock`.
+
### Preferences
`--preferences` is the main user-facing steering knob:
@@ -130,6 +153,26 @@ python -m skillopt_sleep run --backend codex --project "$(pwd)" \
Preferences guide reflection but remain subject to the validation gate.
+### Cursor source and backend
+
+Cursor transcript harvesting is explicit: use `--source cursor` rather than
+`--source auto`. Invoked-project scope uses Cursor's recorded workspace path,
+with the sanitized storage directory as a fallback; `--scope all` scans every
+Cursor workspace under `~/.cursor/projects`. The model-driven backend requires
+an installed, authenticated `cursor-agent`; use `--cursor-path`,
+`SKILLOPT_SLEEP_CURSOR_PATH`, or the `cursor_path` config key when it is not on
+`PATH`, and use `--model` or `SKILLOPT_SLEEP_CURSOR_MODEL` to choose a model.
+
+Target the project skill explicitly so accepted learning becomes visible to
+Cursor without changing the plugin's own workflow skill:
+
+```bash
+python -m skillopt_sleep run --project "$(pwd)" \
+ --source cursor --backend cursor \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \
+ --max-sessions 5 --max-tasks 3 --progress
+```
+
### Advanced config
The JSON/YAML config under `~/.skillopt-sleep/` supports additional engine keys,
@@ -138,6 +181,15 @@ including `gate_mode`, `gate_metric`, `dream_rollouts`, `dream_factor`, `recall_
unsupported CLI flags listed above. Shipping defaults are conservative:
`gate_mode="on"`, `dream_rollouts=1`, `dream_factor=0`, and `recall_k=0`.
+The managed `schedule` command stores only the project, backend, time, and
+optional auto-adopt setting. It does not copy `--source`, `--cursor-home`,
+`--cursor-path`, `--model`, or `--target-skill-path` into the scheduled command.
+For a Cursor schedule, set `transcript_source`, `cursor_home`, `cursor_path`,
+`model`, and `target_skill_path` in `~/.skillopt-sleep/config.json` first. Keep
+the target project-relative, use an absolute CLI path because cron and Task
+Scheduler may have a minimal `PATH`, and confirm that `cursor-agent` is
+authenticated for the account that runs the job.
+
### Handoff backend
`--backend handoff` keeps model subprocesses out of the engine. It writes pending
diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json
new file mode 100644
index 00000000..97252bbd
--- /dev/null
+++ b/plugins/cursor/.cursor-plugin/plugin.json
@@ -0,0 +1,30 @@
+{
+ "name": "skillopt-sleep",
+ "displayName": "SkillOpt-Sleep",
+ "version": "0.1.0",
+ "description": "Review recent Cursor sessions, replay recurring work, and stage validation-gated improvements to a Cursor skill for explicit adoption.",
+ "author": {
+ "name": "Yifan Yang",
+ "email": "yifanyang@microsoft.com"
+ },
+ "homepage": "https://github.com/microsoft/SkillOpt",
+ "repository": "https://github.com/microsoft/SkillOpt",
+ "license": "MIT",
+ "keywords": [
+ "skillopt",
+ "cursor",
+ "self-improvement",
+ "memory-consolidation",
+ "sleep",
+ "skills",
+ "offline-optimization"
+ ],
+ "category": "developer-tools",
+ "tags": [
+ "automation",
+ "memory",
+ "transcripts"
+ ],
+ "commands": "./commands/",
+ "skills": "./skills/"
+}
diff --git a/plugins/cursor/LICENSE b/plugins/cursor/LICENSE
new file mode 100644
index 00000000..cf7bcb2b
--- /dev/null
+++ b/plugins/cursor/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Microsoft Corporation
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/cursor/README.md b/plugins/cursor/README.md
new file mode 100644
index 00000000..498fd36c
--- /dev/null
+++ b/plugins/cursor/README.md
@@ -0,0 +1,246 @@
+# SkillOpt-Sleep - Cursor integration
+
+Give Cursor an on-demand or explicitly scheduled sleep cycle: review recent
+local Cursor sessions, replay recurring tasks through a selected backend, and
+stage validation-gated improvements to a project Cursor skill. Nothing runs at
+session end, and nothing live changes until the user adopts an accepted staged
+proposal (unless they explicitly request `--auto-adopt`).
+
+This package is a native Cursor plugin containing a command and an agent skill.
+It does not install hooks or an MCP server.
+
+## Requirements
+
+- Cursor with plugin and agent-skill support.
+- Python 3.10 or newer.
+- Either a SkillOpt source checkout or an installed `skillopt-sleep` command.
+- For `--backend cursor`, an installed and authenticated Cursor Agent CLI
+ (`cursor-agent`). The default `mock` backend needs no provider login or spend.
+
+The plugin and transcript harvester work on native Windows. Cursor documents
+the Agent CLI for Windows through WSL; run provider-backed `--backend cursor`
+inside WSL unless a native `cursor-agent` is available in your environment.
+
+## Install the local plugin
+
+Clone the repository, then run the installer for your platform.
+
+macOS or Linux:
+
+```bash
+git clone https://github.com/microsoft/SkillOpt.git
+cd SkillOpt
+bash plugins/cursor/install.sh
+export SKILLOPT_SLEEP_REPO="$(pwd)"
+```
+
+Windows PowerShell:
+
+```powershell
+git clone https://github.com/microsoft/SkillOpt.git
+Set-Location SkillOpt
+powershell -File plugins/cursor/install.ps1
+[System.Environment]::SetEnvironmentVariable("SKILLOPT_SLEEP_REPO", "$(pwd)", "User")
+```
+
+The installer copies the plugin to
+`~/.cursor/plugins/local/skillopt-sleep` (or
+`%USERPROFILE%\.cursor\plugins\local\skillopt-sleep`). Quit and reopen Cursor
+after changing user environment variables, then confirm that **SkillOpt-Sleep**
+appears in Settings > Plugins under Installed.
+
+The plugin and engine have separate installation boundaries. The copied plugin
+teaches Cursor how to operate SkillOpt-Sleep; the engine still runs from the
+source checkout through `plugins/run-sleep.sh` / `plugins/run-sleep.ps1`, or
+from an installed command:
+
+```bash
+uv tool install skillopt
+# or: python -m pip install skillopt
+```
+
+Use a release that includes Cursor source/backend support when choosing the
+installed-command route. The source-checkout route uses the implementation in
+the checkout directly.
+
+## Use from Cursor
+
+Run the native command, for example:
+
+```text
+/skillopt-sleep status
+/skillopt-sleep dry-run --backend mock --max-sessions 5 --max-tasks 3
+/skillopt-sleep run --backend cursor --max-sessions 5 --max-tasks 3 --progress
+/skillopt-sleep adopt
+```
+
+The `skillopt-sleep` agent skill remains independently available if a Cursor
+version does not surface plugin commands.
+
+The native command's default Cursor-visible target is:
+
+```text
+.cursor/skills/skillopt-sleep-learned/SKILL.md
+```
+
+Use `--target-skill-path` with that value on harvest, dry-run, and run commands.
+Without an explicit target, the shared engine defaults to a Claude-managed
+skill, which Cursor does not load as a project skill.
+
+### Source-checkout commands
+
+macOS or Linux:
+
+```bash
+bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" status --project "$(pwd)"
+bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" dry-run \
+ --project "$(pwd)" --source cursor --backend mock \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md
+bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" run \
+ --project "$(pwd)" --source cursor --backend cursor \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \
+ --max-sessions 5 --max-tasks 3 --progress
+```
+
+Windows PowerShell:
+
+```powershell
+powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" status --project "$(pwd)"
+powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" dry-run `
+ --project "$(pwd)" --source cursor --backend mock `
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md
+powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" run `
+ --project "$(pwd)" --source cursor --backend cursor `
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md `
+ --max-sessions 5 --max-tasks 3 --progress
+```
+
+### Installed-command equivalents
+
+```bash
+skillopt-sleep status --project "$(pwd)"
+skillopt-sleep dry-run --project "$(pwd)" --source cursor --backend mock \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md
+skillopt-sleep run --project "$(pwd)" --source cursor --backend cursor \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \
+ --max-sessions 5 --max-tasks 3 --progress
+skillopt-sleep adopt --project "$(pwd)"
+```
+
+`--source cursor` reads local JSONL transcripts below
+`~/.cursor/projects//agent-transcripts/`. Use
+`--cursor-home /path/to/.cursor` for a different Cursor home. Invoked scope
+selects the current workspace; `--scope all` includes every Cursor workspace.
+The source converter retains user/assistant text, tool names, and explicit turn
+errors, but excludes raw tool arguments and outputs.
+
+The first harvest uses a 72-hour lookback by default. Use
+`--lookback-hours N` to choose a wider initial window, or
+`--lookback-hours 0` to consider all available history while still respecting
+`--max-sessions`. A successful `run`, including one that mines no tasks,
+records a new harvest checkpoint. Later runs use that checkpoint instead of
+the initial lookback. Use `harvest` or `dry-run` to inspect session and task
+counts before the first stateful run; neither action advances the checkpoint.
+
+`--backend cursor` invokes the authenticated Cursor Agent CLI. Use
+`--cursor-path /path/to/cursor-agent` or `SKILLOPT_SLEEP_CURSOR_PATH` if it is
+not on PATH, and `--model` or `SKILLOPT_SLEEP_CURSOR_MODEL` to override its
+model. Check available identifiers with `cursor-agent --list-models` and verify
+the billed variant in Cursor's usage reporting when cost matters.
+
+## What Cursor replay evaluates
+
+SkillOpt reads the target skill and inserts its text into mined task prompts. It
+does not invoke that file as a native Cursor skill or execute commands described
+by the skill. All ordinary Cursor model calls, including mining, replay,
+judging, and reflection, run in a new empty temporary workspace in read-only Ask
+mode. File reads, file writes, and MCP tools are denied. `--project` selects the
+transcript scope, target files, state, and staging location; it does not make the
+project the Cursor Agent execution workspace.
+
+Cursor tool-aware replay is temporarily disabled pending live Cursor
+permission-boundary validation. Tasks containing a `tool_called` check fail
+nonzero before Agent mode starts. The failed replay does not add a cache entry,
+stage, adopt, persist state, or advance the harvest checkpoint. Use another
+backend for those tasks.
+
+This replay is useful for textual procedures, response conventions, and output
+formats. It is not an end-to-end evaluation of skills that depend on repository
+inspection, real CLIs, browsers, running services, or filesystem changes. There
+is currently no Cursor option that enables a fresh project worktree or real
+project tools. The `replay: mock` report label refers to the prompt-replay mode,
+not to the selected model backend.
+
+The shared engine also supports `mock`, `claude`, `codex`, `copilot`,
+`handoff`, and `azure_openai` backends. Cursor is the native model-driven
+choice for this integration; `mock` remains the no-provider default.
+
+## Review sensitive data before provider calls
+
+Harvesting is local and read-only, and `--backend mock` makes no provider calls.
+Known secret-shaped strings are redacted from harvested Cursor content, and raw
+tool payloads are excluded, but pattern-based redaction is not a guarantee.
+A real backend sends truncated transcript excerpts and derived tasks to that
+backend's provider for mining, replay, judging, and reflection.
+
+Both `run` and `dry-run` perform those real-backend calls; `dry-run` prevents
+staging but does not prevent provider spend. `--max-sessions` and `--max-tasks`
+bound harvested work, not provider calls, tokens, elapsed time, or money. One
+task can require several attempt, judge, and reflection calls. Start with small
+limits and review the provider's usage reporting before increasing them.
+
+For sensitive work, split the flow at the review boundary:
+
+```bash
+skillopt-sleep harvest --project "$(pwd)" --source cursor \
+ --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \
+ --max-sessions 5 --max-tasks 3 --output reviewed-tasks.json
+
+skillopt-sleep dry-run --project "$(pwd)" --backend cursor \
+ --tasks-file reviewed-tasks.json --progress --json
+```
+
+Inspect and redact the JSON, then set its top-level `"reviewed"` field to
+`true`. Real backends reject task files that remain unreviewed. Keep raw
+transcripts, credentials, and task files out of commits.
+
+## Scheduling
+
+Runs remain user-triggered unless the user explicitly schedules them. Before
+scheduling, put the Cursor source and target in
+`~/.skillopt-sleep/config.json`, because the scheduler persists the project,
+backend, time, and optional auto-adopt flag, but not command-line source or
+target overrides:
+
+```json
+{
+ "transcript_source": "cursor",
+ "target_skill_path": ".cursor/skills/skillopt-sleep-learned/SKILL.md",
+ "cursor_home": "/absolute/path/to/.cursor",
+ "backend": "cursor"
+}
+```
+
+Then schedule or remove the managed entry:
+
+```bash
+skillopt-sleep schedule --project "$(pwd)" --backend cursor --hour 3 --minute 17
+skillopt-sleep unschedule --project "$(pwd)"
+```
+
+On Unix this uses cron; on Windows it uses Task Scheduler. Scheduled runs stage
+proposals for later review by default. Do not add `--auto-adopt` unless the user
+has explicitly chosen unattended adoption.
+
+## Adoption and memory
+
+`run` stages accepted proposals under
+`/.skillopt-sleep/staging//`. Read the staged `report.md`
+and show the held-out baseline-to-candidate score plus exact edits before
+running `adopt`. Adoption backs up an existing target before replacing it.
+
+The shared engine may also propose project `CLAUDE.md` memory updates; existing
+memory behavior is unchanged. To restrict a Cursor setup to the explicit Cursor
+skill, set `"evolve_memory": false` in `~/.skillopt-sleep/config.json`.
+
+There is deliberately no session-end hook or automatic plugin execution.
diff --git a/plugins/cursor/commands/skillopt-sleep.md b/plugins/cursor/commands/skillopt-sleep.md
new file mode 100644
index 00000000..a62020fc
--- /dev/null
+++ b/plugins/cursor/commands/skillopt-sleep.md
@@ -0,0 +1,19 @@
+# SkillOpt-Sleep
+
+Use the bundled `skillopt-sleep` skill to run or manage SkillOpt-Sleep for the
+current Cursor workspace.
+
+Requested action: `$ARGUMENTS`
+
+If no action was supplied, use `status`. Preserve all options supplied after
+the action. For `harvest`, `dry-run`, and `run`, ensure the engine receives:
+
+```text
+--project --scope invoked --source cursor --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md
+```
+
+Do not add `--backend cursor` unless the user requested provider-backed replay;
+the repository default remains the no-provider `mock` backend. Follow the
+skill's runner selection, review, data-boundary, scheduling, and adoption rules.
+Never edit the learned skill or `CLAUDE.md` directly as a substitute for the
+engine's staged adoption flow.
diff --git a/plugins/cursor/install.ps1 b/plugins/cursor/install.ps1
new file mode 100644
index 00000000..dd7b232e
--- /dev/null
+++ b/plugins/cursor/install.ps1
@@ -0,0 +1,39 @@
+# Install the SkillOpt-Sleep Cursor integration as a local Cursor plugin on Windows.
+# Idempotent; prints what it does.
+
+$ErrorActionPreference = "Stop"
+
+$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
+$CursorHome = if ($env:CURSOR_HOME) { $env:CURSOR_HOME } else { Join-Path $env:USERPROFILE ".cursor" }
+$PluginDir = Join-Path $CursorHome "plugins\local\skillopt-sleep"
+$SourceDir = Join-Path $RepoRoot "plugins\cursor"
+$ManifestDir = Join-Path $PluginDir ".cursor-plugin"
+$CommandDir = Join-Path $PluginDir "commands"
+$SkillDir = Join-Path $PluginDir "skills\skillopt-sleep"
+
+Write-Output "[install] repo: $RepoRoot"
+
+New-Item -ItemType Directory -Path $ManifestDir -Force | Out-Null
+New-Item -ItemType Directory -Path $CommandDir -Force | Out-Null
+New-Item -ItemType Directory -Path $SkillDir -Force | Out-Null
+Copy-Item (Join-Path $SourceDir ".cursor-plugin\plugin.json") (Join-Path $ManifestDir "plugin.json") -Force
+Copy-Item (Join-Path $SourceDir "commands\skillopt-sleep.md") (Join-Path $CommandDir "skillopt-sleep.md") -Force
+Copy-Item (Join-Path $SourceDir "skills\skillopt-sleep\SKILL.md") (Join-Path $SkillDir "SKILL.md") -Force
+Copy-Item (Join-Path $SourceDir "README.md") (Join-Path $PluginDir "README.md") -Force
+Copy-Item (Join-Path $SourceDir "LICENSE") (Join-Path $PluginDir "LICENSE") -Force
+
+Write-Output "[install] plugin manifest -> $(Join-Path $ManifestDir 'plugin.json')"
+Write-Output "[install] command -> $(Join-Path $CommandDir 'skillopt-sleep.md')"
+Write-Output "[install] skill -> $(Join-Path $SkillDir 'SKILL.md')"
+Write-Output ""
+Write-Output "[install] Quit and reopen Cursor. The plugin should appear in Settings >"
+Write-Output "Plugins under Installed."
+Write-Output ""
+Write-Output "For source-checkout runs, add this user environment variable:"
+Write-Output " [System.Environment]::SetEnvironmentVariable('SKILLOPT_SLEEP_REPO', '$RepoRoot', 'User')"
+Write-Output ""
+Write-Output "Alternatively, install a SkillOpt release that includes Cursor support so the"
+Write-Output "skillopt-sleep command is on PATH."
+Write-Output ""
+Write-Output "Done. Try in Cursor:"
+Write-Output " /skillopt-sleep status"
diff --git a/plugins/cursor/install.sh b/plugins/cursor/install.sh
new file mode 100755
index 00000000..9b75d70e
--- /dev/null
+++ b/plugins/cursor/install.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Install the SkillOpt-Sleep Cursor integration as a local Cursor plugin.
+# Idempotent; prints what it does.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+CURSOR_HOME="${CURSOR_HOME:-$HOME/.cursor}"
+PLUGIN_DIR="$CURSOR_HOME/plugins/local/skillopt-sleep"
+SOURCE_DIR="$REPO_ROOT/plugins/cursor"
+
+echo "[install] repo: $REPO_ROOT"
+
+mkdir -p "$PLUGIN_DIR/.cursor-plugin" "$PLUGIN_DIR/commands" "$PLUGIN_DIR/skills/skillopt-sleep"
+cp "$SOURCE_DIR/.cursor-plugin/plugin.json" "$PLUGIN_DIR/.cursor-plugin/plugin.json"
+cp "$SOURCE_DIR/commands/skillopt-sleep.md" "$PLUGIN_DIR/commands/skillopt-sleep.md"
+cp "$SOURCE_DIR/skills/skillopt-sleep/SKILL.md" "$PLUGIN_DIR/skills/skillopt-sleep/SKILL.md"
+cp "$SOURCE_DIR/README.md" "$PLUGIN_DIR/README.md"
+cp "$SOURCE_DIR/LICENSE" "$PLUGIN_DIR/LICENSE"
+
+echo "[install] plugin manifest -> $PLUGIN_DIR/.cursor-plugin/plugin.json"
+echo "[install] command -> $PLUGIN_DIR/commands/skillopt-sleep.md"
+echo "[install] skill -> $PLUGIN_DIR/skills/skillopt-sleep/SKILL.md"
+
+cat <
+Plugins under Installed.
+
+For source-checkout runs, add this to your shell profile:
+ export SKILLOPT_SLEEP_REPO="$REPO_ROOT"
+
+Alternatively, install a SkillOpt release that includes Cursor support so the
+\`skillopt-sleep\` command is on PATH.
+
+Done. Try in Cursor:
+ /skillopt-sleep status
+EOF
diff --git a/plugins/cursor/skills/skillopt-sleep/SKILL.md b/plugins/cursor/skills/skillopt-sleep/SKILL.md
new file mode 100644
index 00000000..7caa8fc3
--- /dev/null
+++ b/plugins/cursor/skills/skillopt-sleep/SKILL.md
@@ -0,0 +1,218 @@
+---
+name: skillopt-sleep
+description: "Use when the user wants Cursor to learn from recent local sessions, asks for an offline sleep or dream cycle, wants to consolidate recurring work into a Cursor skill, or requests SkillOpt-Sleep status, harvest, dry-run, run, scheduling, review, or adoption. Drives the validation-gated skillopt_sleep engine with Cursor transcripts and the optional Cursor Agent CLI backend."
+---
+
+# SkillOpt-Sleep for Cursor
+
+SkillOpt-Sleep reviews recent local Cursor sessions, mines recurring tasks,
+replays those tasks, and proposes bounded improvements to a project Cursor
+skill. With the default gate enabled, a proposal is accepted only when it
+improves the held-out score. A normal run stages the proposal for review;
+nothing live changes until explicit adoption. There is no model-weight training.
+
+This plugin has no session-end hook and no MCP server. Run the cycle only when
+the user asks, or install a schedule only when the user explicitly requests one.
+
+## Cursor target
+
+Always use this project-relative target for Cursor-visible learning:
+
+```text
+.cursor/skills/skillopt-sleep-learned/SKILL.md
+```
+
+Pass it through `--target-skill-path` on `harvest`, `dry-run`, and `run`.
+Without an explicit target, the shared engine uses a Claude-managed skill under
+`~/.claude/skills`, which is not the intended Cursor project skill.
+
+The shared engine can also evolve project `CLAUDE.md`. If that secondary memory
+target is unwanted, set `"evolve_memory": false` in
+`~/.skillopt-sleep/config.json` before running.
+
+## Choose the runner
+
+Use one of these supported command paths consistently:
+
+1. Source checkout on macOS/Linux:
+ `bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" ...`
+2. Source checkout on Windows:
+ `powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" ...`
+3. Installed engine on any platform:
+ `skillopt-sleep ...`
+
+If `SKILLOPT_SLEEP_REPO` is not set and `skillopt-sleep` is unavailable, stop
+and explain that the engine must be installed or a SkillOpt checkout must be
+selected. Do not substitute a hand-written edit for the engine workflow.
+
+## Core workflow
+
+1. **Harvest** local Cursor JSONL transcripts read-only.
+2. **Mine** recurring, checkable task records from session digests.
+3. **Replay** tasks under the current skill and memory through the selected
+ backend.
+4. **Reflect** on failures and propose bounded edits.
+5. **Gate** the candidate on held-out real tasks.
+6. **Stage** accepted proposals under
+ `/.skillopt-sleep/staging//`.
+7. **Adopt** only after review, backing up existing live targets first.
+
+## Commands
+
+Use the installed-command form below, or replace `skillopt-sleep` with the
+platform-specific source runner described above.
+
+```bash
+TARGET_SKILL=.cursor/skills/skillopt-sleep-learned/SKILL.md
+
+# Inspect current state and the latest staged proposal.
+skillopt-sleep status --project "$(pwd)"
+
+# Inspect mined tasks without provider spend.
+skillopt-sleep harvest --project "$(pwd)" --source cursor \
+ --target-skill-path "$TARGET_SKILL" --max-sessions 5 --max-tasks 3
+
+# First smoke check: deterministic and no provider calls.
+skillopt-sleep dry-run --project "$(pwd)" --source cursor --backend mock \
+ --target-skill-path "$TARGET_SKILL" --max-sessions 5 --max-tasks 3 --json
+
+# Model-driven optimization through the authenticated Cursor Agent CLI.
+skillopt-sleep run --project "$(pwd)" --source cursor --backend cursor \
+ --target-skill-path "$TARGET_SKILL" \
+ --max-sessions 5 --max-tasks 3 --progress
+
+# Apply the latest accepted staged proposal after review.
+skillopt-sleep adopt --project "$(pwd)"
+```
+
+Actions are `status`, `harvest`, `dry-run`, `run`, `adopt`, `schedule`, and
+`unschedule`.
+
+- Default backend is `mock`, which is deterministic and makes no provider calls.
+- `--backend cursor` uses the user's authenticated Cursor Agent CLI budget for
+ model-driven mining, replay, judging, and reflection.
+- `--source cursor` reads
+ `~/.cursor/projects//agent-transcripts/*/*.jsonl`.
+- `--cursor-home PATH` overrides the Cursor home used for harvesting.
+- `--scope invoked` selects the current workspace; `--scope all` includes every
+ Cursor workspace.
+- `--cursor-path PATH` or `SKILLOPT_SLEEP_CURSOR_PATH` selects a non-default
+ `cursor-agent` executable.
+- `--model NAME` or `SKILLOPT_SLEEP_CURSOR_MODEL` overrides the Cursor model.
+- Check model identifiers with `cursor-agent --list-models`; when cost matters,
+ verify the billed variant in Cursor's usage reporting.
+- Keep live runs bounded with `--max-sessions`, `--max-tasks`, and `--progress`.
+- A held-out gain is evidence for that run, not a promise of general improvement.
+
+The first harvest uses a 72-hour lookback. Use `--lookback-hours N` for a wider
+initial window or `--lookback-hours 0` for all available history. A stateful
+`run`, including a no-task run, records a harvest checkpoint; later runs use the
+checkpoint rather than the initial lookback. Inspect counts with `harvest` or
+`dry-run` before the first real run because those actions do not advance state.
+
+Available backends are:
+
+- `mock` - deterministic, with no provider calls (default);
+- `cursor` - the authenticated Cursor Agent CLI;
+- `claude` - the authenticated Claude CLI;
+- `codex` - the authenticated Codex CLI;
+- `copilot` - the authenticated GitHub Copilot CLI;
+- `handoff` - prompt/answer files for an interactive agent session;
+- `azure_openai` - the configured Azure OpenAI endpoint.
+
+SkillOpt reads the target skill and inserts its text into replay prompts; it does
+not invoke the file as a native Cursor skill. Ordinary Cursor backend calls run
+in a new empty temporary workspace in read-only Ask mode. File reads, file
+writes, and MCP tools are denied. `--project` controls harvesting, target files,
+state, and staging; it is not the Cursor Agent execution workspace.
+
+Cursor tool-aware replay is temporarily disabled pending live Cursor
+permission-boundary validation. A task containing a `tool_called` check fails
+nonzero before Agent mode starts. The failed replay does not add a cache entry,
+stage, adopt, persist state, or advance the harvest checkpoint. Use another
+backend for those tasks. Do not claim that repository- or tool-dependent
+behavior was validated. The current engine does not implement a fresh-worktree
+replay for Cursor.
+
+A real-backend `dry-run` still makes provider calls; it only suppresses staging.
+Session and task limits are workload bounds, not hard limits on calls, tokens,
+time, or money. Start with small limits.
+
+## Reviewable data path
+
+Cursor harvesting retains user/assistant text, tool names, and explicit turn
+errors while excluding raw tool arguments, tool outputs, and non-message
+records. Known secret-shaped strings are redacted, but pattern-based redaction
+cannot guarantee that a transcript is safe to send to a provider.
+
+For sensitive sessions, export tasks before any real-backend replay:
+
+```bash
+TARGET_SKILL=.cursor/skills/skillopt-sleep-learned/SKILL.md
+skillopt-sleep harvest --project "$(pwd)" --source cursor \
+ --target-skill-path "$TARGET_SKILL" \
+ --max-sessions 5 --max-tasks 3 --output reviewed-tasks.json
+```
+
+Inspect and redact the file, then set its top-level `"reviewed"` field to
+`true`. Only then run:
+
+```bash
+skillopt-sleep dry-run --project "$(pwd)" --backend cursor \
+ --tasks-file reviewed-tasks.json --progress --json
+```
+
+Real backends reject task files that remain unreviewed. Never include raw
+transcripts, credentials, secrets, or sensitive task content in messages,
+commits, or generated summaries.
+
+## Scheduling
+
+Scheduling is opt-in. The scheduler persists project, backend, time, and the
+optional auto-adopt flag, but not `--source`, Cursor path/home/model overrides,
+or `--target-skill-path`. Before scheduling a Cursor cycle, set at least these values in
+`~/.skillopt-sleep/config.json`:
+
+```json
+{
+ "transcript_source": "cursor",
+ "target_skill_path": ".cursor/skills/skillopt-sleep-learned/SKILL.md",
+ "backend": "cursor"
+}
+```
+
+Then run:
+
+```bash
+skillopt-sleep schedule --project "$(pwd)" --backend cursor --hour 3 --minute 17
+skillopt-sleep unschedule --project "$(pwd)"
+```
+
+The scheduler uses cron on Unix and Task Scheduler on Windows. Scheduled runs
+stage proposals by default. Use `--auto-adopt` only when the user has explicitly
+requested unattended adoption.
+
+## Report results
+
+For `dry-run` and `run`, report:
+
+- session and task counts;
+- held-out baseline and candidate scores;
+- gate action and accepted/rejected edit counts;
+- exact proposed edits;
+- staging directory, when one was created.
+
+Read staged `report.md` before summarizing a run. Offer adoption only after the
+user reviews an accepted proposal that is still staged. Never claim broad
+improvement from one run.
+
+## Hard rules
+
+- Harvest is read-only. Never edit Cursor transcript files.
+- Never hand-edit the target skill or `CLAUDE.md` as a substitute for adoption.
+- Do not run a real backend on sensitive content without confirming its data
+ boundary or using the reviewed-task workflow.
+- Do not add a session-end hook or imply that installing this plugin schedules
+ anything.
+- Show validation evidence before recommending adoption.
+- Treat generated edits as proposals, not as source of truth.
diff --git a/plugins/openclaw/run_sleep.py b/plugins/openclaw/run_sleep.py
index 516d7585..3ec50da2 100755
--- a/plugins/openclaw/run_sleep.py
+++ b/plugins/openclaw/run_sleep.py
@@ -35,10 +35,16 @@
# Patch get_backend to know about our backend
_orig_get_backend = _b.get_backend
-def get_backend(name, model="", codex_path=""):
+def get_backend(name, model="", codex_path="", cursor_path="", project_dir=""):
if name == "openclaw-deepseek":
return OpenClawDeepSeekBackend(model=model or "deepseek-v4-pro")
- return _orig_get_backend(name, model=model, codex_path=codex_path)
+ return _orig_get_backend(
+ name,
+ model=model,
+ codex_path=codex_path,
+ cursor_path=cursor_path,
+ project_dir=project_dir,
+ )
_b.get_backend = get_backend
diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py
index 5a761b66..d3bbf43d 100644
--- a/skillopt_sleep/__main__.py
+++ b/skillopt_sleep/__main__.py
@@ -13,8 +13,8 @@
--max-tasks N cap mined tasks per run
--target-skill-path PATH explicit live SKILL.md to stage/adopt
--tasks-file PATH reviewed TaskRecord JSON file to replay instead of harvesting
- --backend mock|claude|codex|copilot|handoff
- --source claude|codex|auto
+ --backend mock|claude|codex|copilot|cursor|handoff
+ --source claude|codex|cursor|auto
--model NAME
--lookback-hours N
--auto-adopt
@@ -28,6 +28,7 @@
import sys
from typing import Any, Dict
+from skillopt_sleep.backend import CursorBackendError
from skillopt_sleep.config import load_config
from skillopt_sleep.cycle import run_sleep_cycle
from skillopt_sleep.harvest_sources import harvest_for_config
@@ -70,13 +71,15 @@ def _add_common(p: argparse.ArgumentParser) -> None:
p.add_argument("--project", default="")
p.add_argument("--scope", default="", choices=["", "all", "invoked"])
p.add_argument("--backend", default="",
- choices=["", "mock", "claude", "codex", "copilot", "handoff",
+ choices=["", "mock", "claude", "codex", "copilot", "cursor", "handoff",
"azure_openai"])
p.add_argument("--model", default="")
p.add_argument("--codex-path", default="", help="path to the real @openai/codex binary")
+ p.add_argument("--cursor-path", default="", help="path to the Cursor Agent CLI")
p.add_argument("--claude-home", default="", help="override ~/.claude (also isolates state)")
p.add_argument("--codex-home", default="", help="override ~/.codex for archived session harvest")
- p.add_argument("--source", default="", choices=["", "claude", "codex", "auto"],
+ p.add_argument("--cursor-home", default="", help="override ~/.cursor for Cursor session harvest")
+ p.add_argument("--source", default="", choices=["", "claude", "codex", "cursor", "auto"],
help="session transcript source")
p.add_argument("--lookback-hours", type=int, default=None,
help="harvest window in hours; 0 = scan full history")
@@ -110,10 +113,14 @@ def _cfg_from_args(args, task_meta: Dict[str, Any] | None = None) -> Any:
overrides["model"] = args.model
if getattr(args, "codex_path", ""):
overrides["codex_path"] = os.path.abspath(args.codex_path)
+ if getattr(args, "cursor_path", ""):
+ overrides["cursor_path"] = os.path.abspath(os.path.expanduser(args.cursor_path))
if getattr(args, "claude_home", ""):
overrides["claude_home"] = os.path.abspath(args.claude_home)
if getattr(args, "codex_home", ""):
overrides["codex_home"] = os.path.abspath(args.codex_home)
+ if getattr(args, "cursor_home", ""):
+ overrides["cursor_home"] = os.path.abspath(os.path.expanduser(args.cursor_home))
if getattr(args, "source", ""):
overrides["transcript_source"] = args.source
lh = getattr(args, "lookback_hours", None)
@@ -164,7 +171,11 @@ def cmd_run(args, dry: bool = False) -> int:
return 2
if cfg.get("backend", "mock") == "handoff":
return _run_handoff(cfg, args, seed_tasks=tasks, task_meta=task_meta, dry=dry)
- outcome = run_sleep_cycle(cfg, seed_tasks=tasks, dry_run=dry)
+ try:
+ outcome = run_sleep_cycle(cfg, seed_tasks=tasks, dry_run=dry)
+ except CursorBackendError as exc:
+ print(f"[sleep] Cursor backend failed: {_redact_deep(str(exc))}", file=sys.stderr)
+ return 1
_print_run_report(outcome, args, task_meta)
return 0
diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py
index cd130a48..dff640d3 100644
--- a/skillopt_sleep/backend.py
+++ b/skillopt_sleep/backend.py
@@ -1007,6 +1007,27 @@ def resolve_copilot_path(explicit: str = "") -> str:
return found or "copilot"
+def resolve_cursor_path(explicit: str = "") -> str:
+ """Find the Cursor Agent CLI (``cursor-agent``)."""
+ if explicit:
+ return os.path.expanduser(explicit)
+ env = os.environ.get("SKILLOPT_SLEEP_CURSOR_PATH")
+ if env:
+ return os.path.expanduser(env)
+ import shutil
+
+ found = shutil.which("cursor-agent")
+ return found or "cursor-agent"
+
+
+class CursorBackendError(RuntimeError):
+ """A redacted Cursor Agent process or response failure."""
+
+ def __init__(self, message: str, *, retryable: bool = False) -> None:
+ super().__init__(message)
+ self.retryable = retryable
+
+
class CopilotCliBackend(CliBackend):
"""Drives the GitHub Copilot CLI in non-interactive mode.
@@ -1207,6 +1228,221 @@ def attempt_with_tools(self, task, skill, memory, tools):
pass
+class CursorCliBackend(CliBackend):
+ """Drive an authenticated Cursor Agent CLI in an isolated workspace.
+
+ Cursor's JSON print format has one final ``result`` object. Ordinary calls
+ use Ask mode, which is read-only. Tool-aware replay is disabled until the
+ Cursor Agent permission boundary has been validated against the live CLI.
+ """
+
+ name = "cursor"
+ _AUTH_ERROR_MARKERS = (
+ "not authenticated",
+ "authentication required",
+ "not logged in",
+ "please log in",
+ "login required",
+ "unauthorized",
+ "invalid api key",
+ "401",
+ "403",
+ )
+ _CONFIG_ERROR_MARKERS = (
+ "unknown option",
+ "invalid option",
+ "invalid model",
+ "unsupported model",
+ "model not found",
+ "not available for your account",
+ "invalid configuration",
+ )
+ _ASK_DENY = ["Read(**)", "Write(**)", "Mcp(*:*)"]
+
+ def __init__(self, model: str = "", cursor_path: str = "", timeout: int = 240) -> None:
+ super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_CURSOR_MODEL", ""), timeout=timeout)
+ self.cursor_path = resolve_cursor_path(cursor_path)
+
+ def _command(self, workspace: str) -> List[str]:
+ cmd = [
+ self.cursor_path,
+ "-p",
+ "--output-format",
+ "json",
+ "--trust",
+ "--workspace",
+ workspace,
+ "--mode",
+ "ask",
+ ]
+ if self.model:
+ cmd += ["--model", self.model]
+ return cmd
+
+ @staticmethod
+ def _terminal_result(raw: str) -> Optional[Dict[str, Any]]:
+ candidates = [raw.strip()] if raw.strip() else []
+ candidates.extend(line.strip() for line in raw.splitlines() if line.strip().startswith("{"))
+ for candidate in reversed(candidates):
+ try:
+ obj = json.loads(candidate)
+ except Exception:
+ continue
+ if isinstance(obj, dict) and obj.get("type") == "result":
+ return obj
+ return None
+
+ @classmethod
+ def _parse_json_response(cls, raw: str) -> str:
+ """Return text from the terminal successful Cursor result."""
+ terminal = cls._terminal_result(raw)
+ if terminal is None or terminal.get("is_error") is True:
+ return ""
+ result = terminal.get("result")
+ if isinstance(result, str):
+ return result.strip()
+ return ""
+
+ def _error(self, message: str, *, retryable: bool = False) -> CursorBackendError:
+ import logging
+
+ from skillopt_sleep.staging import redact_secrets
+
+ self.last_call_error = str(redact_secrets(message))[:500]
+ logging.getLogger("skillopt_sleep").warning("Cursor Agent call failed: %s", self.last_call_error)
+ return CursorBackendError(self.last_call_error, retryable=retryable)
+
+ @staticmethod
+ def _isolated_environment(runtime_dir: str) -> Dict[str, str]:
+ config_dir = os.path.join(runtime_dir, "config")
+ data_dir = os.path.join(runtime_dir, "data")
+ os.makedirs(config_dir, exist_ok=True)
+ os.makedirs(data_dir, exist_ok=True)
+ with open(os.path.join(config_dir, "cli-config.json"), "w", encoding="utf-8") as f:
+ json.dump(
+ {
+ "version": 1,
+ "editor": {"vimMode": False},
+ "permissions": {
+ "allow": [],
+ "deny": CursorCliBackend._ASK_DENY,
+ },
+ "approvalMode": "allowlist",
+ "sandbox": {
+ "mode": "disabled",
+ "networkAccess": "user_config_only",
+ "networkAllowlist": [],
+ },
+ "network": {"useHttp1ForAgent": False},
+ "hasChangedDefaultModel": False,
+ "attribution": {
+ "attributeCommitsToAgent": False,
+ "attributePRsToAgent": False,
+ },
+ },
+ f,
+ indent=2,
+ )
+ env = os.environ.copy()
+ env["CURSOR_CONFIG_DIR"] = config_dir
+ env["CURSOR_DATA_DIR"] = data_dir
+ return env
+
+ def _invoke_once(self, prompt: str, workspace: str) -> str:
+ import shutil
+
+ from skillopt_sleep.harvest_cursor import CURSOR_REPLAY_SENTINEL
+
+ self.last_call_error = ""
+ replay_prompt = CURSOR_REPLAY_SENTINEL + "\n\n" + prompt
+ runtime_dir = ""
+ try:
+ runtime_dir = tempfile.mkdtemp(prefix="skillopt_sleep_cursor_runtime_")
+ proc = subprocess.run(
+ self._command(workspace),
+ capture_output=True,
+ creationflags=_NO_WINDOW,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=self.timeout,
+ cwd=workspace,
+ input=replay_prompt,
+ env=self._isolated_environment(runtime_dir),
+ )
+ except subprocess.TimeoutExpired:
+ raise self._error(
+ f"Cursor Agent timed out after {self.timeout}s",
+ retryable=True,
+ )
+ except Exception as exc:
+ raise self._error(f"Cursor Agent spawn failed: {exc}")
+ finally:
+ if runtime_dir:
+ shutil.rmtree(runtime_dir, ignore_errors=True)
+
+ if proc.returncode != 0:
+ raise self._error(
+ f"Cursor Agent exited {proc.returncode}: {(proc.stderr or '')[:500]}"
+ )
+ terminal = self._terminal_result(proc.stdout or "")
+ if terminal is not None and terminal.get("is_error") is True:
+ detail = terminal.get("result") or terminal.get("error") or proc.stderr or ""
+ raise self._error(f"Cursor Agent returned an error result: {str(detail)[:300]}")
+ output = self._parse_json_response(proc.stdout or "")
+ if not output:
+ detail = (proc.stderr or "").strip()
+ if any(marker in detail.casefold() for marker in self._AUTH_ERROR_MARKERS):
+ raise self._error(
+ "Cursor Agent authentication failed"
+ + (f": {detail[:300]}" if detail else "")
+ )
+ if any(marker in detail.casefold() for marker in self._CONFIG_ERROR_MARKERS):
+ raise self._error(
+ "Cursor Agent configuration failed"
+ + (f": {detail[:300]}" if detail else "")
+ )
+ raise self._error(
+ "Cursor Agent returned no usable JSON response"
+ + (f": {detail[:300]}" if detail else ""),
+ retryable=True,
+ )
+ self.last_call_error = ""
+ return output
+
+ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
+ del max_tokens
+ workspace = tempfile.mkdtemp(prefix="skillopt_sleep_cursor_")
+ try:
+ for attempt in range(2):
+ try:
+ return self._invoke_once(prompt, workspace)
+ except CursorBackendError as exc:
+ if not exc.retryable or attempt == 1:
+ raise
+ raise AssertionError("unreachable")
+ finally:
+ try:
+ import shutil
+
+ shutil.rmtree(workspace, ignore_errors=True)
+ except Exception:
+ pass
+
+ def attempt_with_tools(
+ self,
+ task: TaskRecord,
+ skill: str,
+ memory: str,
+ tools: List[str],
+ ) -> Tuple[str, List[str]]:
+ del task, skill, memory, tools
+ raise self._error(
+ "Cursor tool-aware replay is temporarily disabled pending live "
+ "Cursor permission-boundary validation"
+ )
+
+
class DualBackend(Backend):
"""Route operations to two backends, à la SkillOpt's target vs optimizer.
@@ -1564,6 +1800,7 @@ def get_backend(
model: str = "",
claude_path: str = "claude",
codex_path: str = "",
+ cursor_path: str = "",
azure_endpoint: str = "",
project_dir: str = "",
) -> Backend:
@@ -1579,6 +1816,8 @@ def get_backend(
return AzureResponsesBackend(deployment=model, endpoints=eps)
if n in {"copilot", "github_copilot", "copilot_cli", "gh_copilot"}:
return CopilotCliBackend(model=model)
+ if n in {"cursor", "cursor_agent", "cursor_cli"}:
+ return CursorCliBackend(model=model, cursor_path=cursor_path)
if n in {"handoff", "session", "file"}:
# Lazy import: handoff_backend imports CliBackend from this module.
from skillopt_sleep.handoff_backend import HandoffBackend
@@ -1598,6 +1837,7 @@ def build_backend(
target_backend: str = "",
target_model: str = "",
codex_path: str = "",
+ cursor_path: str = "",
azure_endpoint: str = "",
preferences: str = "",
project_dir: str = "",
@@ -1615,16 +1855,17 @@ def build_backend(
backend,
model=model,
codex_path=codex_path,
+ cursor_path=cursor_path,
azure_endpoint=azure_endpoint,
project_dir=project_dir,
)
be.preferences = preferences
return be
tgt = get_backend(target_backend or backend, model=target_model or model,
- codex_path=codex_path, azure_endpoint=azure_endpoint,
+ codex_path=codex_path, cursor_path=cursor_path, azure_endpoint=azure_endpoint,
project_dir=project_dir)
opt = get_backend(optimizer_backend or backend, model=optimizer_model or model,
- codex_path=codex_path, azure_endpoint=azure_endpoint,
+ codex_path=codex_path, cursor_path=cursor_path, azure_endpoint=azure_endpoint,
project_dir=project_dir)
opt.preferences = preferences # reflect runs on the optimizer
dual = DualBackend(target=tgt, optimizer=opt)
diff --git a/skillopt_sleep/config.py b/skillopt_sleep/config.py
index 4a1c992f..772017e4 100644
--- a/skillopt_sleep/config.py
+++ b/skillopt_sleep/config.py
@@ -19,13 +19,15 @@
HOME_STATE_DIR = os.path.expanduser("~/.skillopt-sleep")
CLAUDE_HOME = os.path.expanduser("~/.claude")
CODEX_HOME = os.path.expanduser("~/.codex")
+CURSOR_HOME = os.path.expanduser("~/.cursor")
DEFAULTS: Dict[str, Any] = {
# ── scope ──────────────────────────────────────────────────────────────
"claude_home": CLAUDE_HOME,
"codex_home": CODEX_HOME,
- "transcript_source": "claude", # "claude" | "codex" | "auto"
+ "cursor_home": CURSOR_HOME,
+ "transcript_source": "claude", # "claude" | "codex" | "cursor" | "auto"
"projects": "invoked", # "invoked" | "all" | [list of abs paths]
"invoked_project": "", # filled at runtime (cwd) when projects == "invoked"
"lookback_hours": 72, # harvest window when no prior sleep recorded
@@ -36,15 +38,16 @@
"val_fraction": 0.34, # real tasks reserved to gate updates
"test_fraction": 0.0, # real tasks reserved as the final held-out measure
# ── optimizer ──────────────────────────────────────────────────────────
- "backend": "mock", # "mock" | "claude" | "codex" | "copilot"
+ "backend": "mock", # "mock" | "claude" | "codex" | "copilot" | "cursor"
"model": "", # backend-specific; "" => backend default
"gate_mode": "on", # "on" (validation-gated) | "off" (greedy, no hard filter)
"codex_path": "", # "" => auto-detect the real @openai/codex binary
+ "cursor_path": "", # "" => auto-detect the Cursor Agent CLI
"edit_budget": 4, # textual learning rate (max edits/night)
"preferences": "", # free-text house rules injected into reflect as a prior
"gate_metric": "mixed", # hard | soft | mixed (mixed best for tiny holdouts)
"gate_mixed_weight": 0.5,
- "replay_mode": "mock", # "mock" (sandboxed prompt) | "fresh" (worktree)
+ "replay_mode": "mock", # report label; fresh-worktree replay is not implemented
# ── dream + recall (opt-in; defaults reproduce the prior single-shot loop) ─
"dream_rollouts": 1, # >1 => multi-rollout contrastive reflection per task
"dream_factor": 0, # >0 => add N synthetic variants of each task to the dream
@@ -108,6 +111,11 @@ def transcripts_dir(self) -> str:
def codex_archived_sessions_dir(self) -> str:
return os.path.join(self.data["codex_home"], "archived_sessions")
+ @property
+ def cursor_projects_dir(self) -> str:
+ cursor_home = os.path.abspath(os.path.expanduser(str(self.data["cursor_home"])))
+ return os.path.join(cursor_home, "projects")
+
@property
def history_path(self) -> str:
return os.path.join(self.data["claude_home"], "history.jsonl")
diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py
index f199211d..a94b8ec0 100644
--- a/skillopt_sleep/cycle.py
+++ b/skillopt_sleep/cycle.py
@@ -118,6 +118,7 @@ def run_sleep_cycle(
cfg.get("backend", "mock"),
model=cfg.get("model", ""),
codex_path=cfg.get("codex_path", ""),
+ cursor_path=cfg.get("cursor_path", ""),
project_dir=project,
)
backend.preferences = cfg.get("preferences", "")
diff --git a/skillopt_sleep/harvest_cursor.py b/skillopt_sleep/harvest_cursor.py
new file mode 100644
index 00000000..c4379a49
--- /dev/null
+++ b/skillopt_sleep/harvest_cursor.py
@@ -0,0 +1,316 @@
+"""Read Cursor Agent transcripts and normalize them into session digests.
+
+Cursor writes workspace-scoped JSONL under
+``~/.cursor/projects//agent-transcripts//.jsonl``.
+The observed local records contain user/assistant messages and tool-use metadata
+but no timestamps, so this harvester uses each file's mtime as its end time.
+Tool inputs and outputs are intentionally never copied.
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+from datetime import datetime, timezone
+from typing import Any, Iterable, List, Optional
+
+from skillopt_sleep.harvest import (
+ _detect_feedback,
+ _is_meta_prompt,
+ _iter_jsonl,
+)
+from skillopt_sleep.staging import redact_secrets
+from skillopt_sleep.types import SessionDigest
+
+CURSOR_REPLAY_SENTINEL = ""
+_CURSOR_USER_QUERY_RE = re.compile(
+ r"\s*(.*?)\s*\s*\Z",
+ re.DOTALL,
+)
+
+
+def cursor_project_slug(project: str) -> str:
+ """Return the filesystem-safe workspace name used under Cursor projects."""
+ normalized = os.path.abspath(os.path.expanduser(project))
+ return re.sub(r"[^A-Za-z0-9]+", "-", normalized).strip("-")
+
+
+def _text_from_content(content: Any) -> str:
+ if isinstance(content, str):
+ return content
+ if not isinstance(content, list):
+ return ""
+ return "\n".join(
+ str(block["text"])
+ for block in content
+ if isinstance(block, dict)
+ and block.get("type") == "text"
+ and block.get("text")
+ )
+
+
+def _tool_names(content: Any) -> List[str]:
+ if not isinstance(content, list):
+ return []
+ names: List[str] = []
+ for block in content:
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
+ continue
+ name = block.get("name")
+ if isinstance(name, str) and name:
+ names.append(re.sub(r"[^A-Za-z0-9_.:-]+", "_", name)[:80])
+ return names
+
+
+def _sanitize_text(text: str) -> str:
+ sanitized = str(redact_secrets(text)).replace("\x00", "").strip()
+ user_query = _CURSOR_USER_QUERY_RE.search(sanitized)
+ if user_query:
+ sanitized = user_query.group(1).strip()
+ if not sanitized or _is_meta_prompt(sanitized):
+ return ""
+ return sanitized
+
+
+def _dedup(values: Iterable[str]) -> List[str]:
+ seen = set()
+ result: List[str] = []
+ for value in values:
+ if value not in seen:
+ seen.add(value)
+ result.append(value)
+ return result
+
+
+def _mtime_iso(path: str) -> str:
+ try:
+ return (
+ datetime.fromtimestamp(os.path.getmtime(path), tz=timezone.utc)
+ .replace(microsecond=0)
+ .isoformat()
+ .replace("+00:00", "Z")
+ )
+ except OSError:
+ return ""
+
+
+def _mtime(path: str) -> Optional[float]:
+ try:
+ return os.path.getmtime(path)
+ except OSError:
+ return None
+
+
+def _iso_epoch(value: Optional[str]) -> Optional[float]:
+ if not value:
+ return None
+ try:
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
+ parsed = datetime.fromisoformat(normalized)
+ # Existing state timestamps are local-time strings without an offset.
+ return parsed.timestamp()
+ except (TypeError, ValueError, OSError):
+ return None
+
+
+def digest_cursor_transcript(path: str, *, project: str = "") -> Optional[SessionDigest]:
+ """Build a digest without retaining Cursor tool arguments or outputs."""
+ session_id = os.path.splitext(os.path.basename(path))[0]
+ user_prompts: List[str] = []
+ assistant_finals: List[str] = []
+ tools: List[str] = []
+ feedback: List[str] = []
+ n_user = 0
+ n_assistant = 0
+
+ for record in _iter_jsonl(path):
+ if not isinstance(record, dict):
+ continue
+ if record.get("type") == "turn_ended":
+ if record.get("status") == "error":
+ feedback.append("neg:cursor_turn_error")
+ continue
+
+ message = record.get("message")
+ if not isinstance(message, dict):
+ continue
+ role = record.get("role") or message.get("role")
+ content = message.get("content")
+ if role == "user":
+ text = _sanitize_text(_text_from_content(content))
+ if text:
+ n_user += 1
+ user_prompts.append(text)
+ feedback.extend(_detect_feedback(text))
+ elif role == "assistant":
+ n_assistant += 1
+ tools.extend(_tool_names(content))
+ text = _sanitize_text(_text_from_content(content))
+ if text:
+ assistant_finals.append(text)
+
+ if n_user == 0 and n_assistant == 0:
+ return None
+
+ return SessionDigest(
+ session_id=session_id,
+ project=project,
+ ended_at=_mtime_iso(path),
+ user_prompts=user_prompts,
+ assistant_finals=assistant_finals[-5:],
+ tools_used=_dedup(tools),
+ files_touched=[],
+ feedback_signals=feedback,
+ n_user_turns=n_user,
+ n_assistant_turns=n_assistant,
+ raw_path=path,
+ )
+
+
+def _workspace_path(project_dir: str) -> str:
+ metadata_path = os.path.join(project_dir, ".workspace-trusted")
+ try:
+ with open(metadata_path, encoding="utf-8") as f:
+ metadata = json.load(f)
+ except (OSError, ValueError):
+ return ""
+ if not isinstance(metadata, dict):
+ return ""
+ workspace = metadata.get("workspacePath")
+ if not isinstance(workspace, str) or not workspace.strip():
+ return ""
+ workspace = os.path.expanduser(workspace.strip())
+ if not os.path.isabs(workspace):
+ return ""
+ return os.path.abspath(workspace)
+
+
+def _normalized_path(path: str) -> str:
+ return os.path.normcase(os.path.realpath(os.path.abspath(os.path.expanduser(path))))
+
+
+def _is_workspace_ancestor(workspace: str, invoked: str) -> bool:
+ try:
+ workspace_norm = _normalized_path(workspace)
+ invoked_norm = _normalized_path(invoked)
+ return os.path.commonpath([workspace_norm, invoked_norm]) == workspace_norm
+ except (OSError, ValueError):
+ return False
+
+
+def _available_project_dirs(projects_dir: str) -> List[tuple[str, str, str]]:
+ try:
+ names = sorted(os.listdir(projects_dir))
+ except OSError:
+ return []
+ result: List[tuple[str, str, str]] = []
+ for name in names:
+ project_dir = os.path.join(projects_dir, name)
+ if os.path.isdir(os.path.join(project_dir, "agent-transcripts")):
+ result.append((project_dir, name, _workspace_path(project_dir)))
+ return result
+
+
+def _project_dirs(projects_dir: str, scope: Any, invoked_project: str) -> List[tuple[str, str]]:
+ available = _available_project_dirs(projects_dir)
+ if scope == "all":
+ return [
+ (project_dir, workspace or name)
+ for project_dir, name, workspace in available
+ ]
+
+ projects: List[str]
+ if isinstance(scope, (list, tuple)):
+ projects = [str(project) for project in scope]
+ else:
+ projects = [invoked_project] if invoked_project else []
+
+ selected: List[tuple[str, str]] = []
+ seen = set()
+ for project in projects:
+ absolute_project = os.path.abspath(os.path.expanduser(project))
+ matches = [
+ (project_dir, workspace)
+ for project_dir, _name, workspace in available
+ if workspace and _is_workspace_ancestor(workspace, absolute_project)
+ ]
+ candidate = absolute_project
+ while candidate:
+ fallback = os.path.join(projects_dir, cursor_project_slug(candidate))
+ if os.path.isdir(os.path.join(fallback, "agent-transcripts")):
+ matches.append((fallback, candidate))
+ break
+ parent = os.path.dirname(candidate)
+ if parent == candidate:
+ break
+ candidate = parent
+
+ if matches:
+ longest = max(len(_normalized_path(workspace)) for _project_dir, workspace in matches)
+ choices = [
+ (project_dir, workspace)
+ for project_dir, workspace in matches
+ if len(_normalized_path(workspace)) == longest
+ ]
+ else:
+ fallback = os.path.join(projects_dir, cursor_project_slug(absolute_project))
+ choices = [(fallback, absolute_project)]
+ for project_dir, workspace in choices:
+ if project_dir not in seen:
+ selected.append((project_dir, workspace))
+ seen.add(project_dir)
+ return selected
+
+
+def _is_cursor_replay(digest: SessionDigest) -> bool:
+ return any(prompt.lstrip().startswith(CURSOR_REPLAY_SENTINEL) for prompt in digest.user_prompts)
+
+
+def harvest_cursor(
+ projects_dir: str,
+ *,
+ scope: Any = "all",
+ invoked_project: str = "",
+ since_iso: Optional[str] = None,
+ limit: int = 0,
+) -> List[SessionDigest]:
+ """Return Cursor session digests for the selected workspace scope."""
+ if not os.path.isdir(projects_dir):
+ return []
+
+ candidates: List[tuple[str, str, float]] = []
+ for project_dir, project in _project_dirs(projects_dir, scope, invoked_project):
+ transcripts_dir = os.path.join(project_dir, "agent-transcripts")
+ try:
+ session_names = sorted(os.listdir(transcripts_dir))
+ except OSError:
+ continue
+ for session_name in session_names:
+ session_dir = os.path.join(transcripts_dir, session_name)
+ if not os.path.isdir(session_dir):
+ continue
+ try:
+ filenames = sorted(os.listdir(session_dir))
+ except OSError:
+ continue
+ for filename in filenames:
+ path = os.path.join(session_dir, filename)
+ if not filename.endswith(".jsonl") or not os.path.isfile(path):
+ continue
+ modified = _mtime(path)
+ if modified is not None:
+ candidates.append((path, project, modified))
+ candidates.sort(key=lambda item: (-item[2], item[0]))
+
+ since_epoch = _iso_epoch(since_iso)
+ digests: List[SessionDigest] = []
+ for path, project, modified in candidates:
+ if since_epoch is not None and modified <= since_epoch:
+ continue
+ digest = digest_cursor_transcript(path, project=project)
+ if digest is None or _is_cursor_replay(digest):
+ continue
+ digests.append(digest)
+ if limit and len(digests) >= limit:
+ break
+ return digests
diff --git a/skillopt_sleep/harvest_sources.py b/skillopt_sleep/harvest_sources.py
index 501aa285..0b949601 100644
--- a/skillopt_sleep/harvest_sources.py
+++ b/skillopt_sleep/harvest_sources.py
@@ -5,6 +5,7 @@
from skillopt_sleep.harvest import harvest
from skillopt_sleep.harvest_codex import harvest_codex
+from skillopt_sleep.harvest_cursor import harvest_cursor
from skillopt_sleep.types import SessionDigest
@@ -21,6 +22,14 @@ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0)
since_iso=since_iso,
limit=limit,
)
+ if source == "cursor":
+ return harvest_cursor(
+ cfg.cursor_projects_dir,
+ scope=scope,
+ invoked_project=invoked_project,
+ since_iso=since_iso,
+ limit=limit,
+ )
if source == "auto":
codex_digests = harvest_codex(
cfg.codex_archived_sessions_dir,
diff --git a/skillopt_sleep/mine.py b/skillopt_sleep/mine.py
index 44830574..9a258274 100644
--- a/skillopt_sleep/mine.py
+++ b/skillopt_sleep/mine.py
@@ -20,6 +20,7 @@
from collections import Counter
from typing import Any, Callable, List, Optional, Set, Tuple
+from skillopt_sleep.backend import CursorBackendError
from skillopt_sleep.types import SessionDigest, TaskRecord
@@ -300,6 +301,8 @@ def mine(
if llm_miner is not None:
try:
tasks = llm_miner(digests) or []
+ except CursorBackendError:
+ raise
except Exception:
tasks = []
if not tasks:
diff --git a/skillopt_sleep/replay.py b/skillopt_sleep/replay.py
index e15f3dfe..62404962 100644
--- a/skillopt_sleep/replay.py
+++ b/skillopt_sleep/replay.py
@@ -4,8 +4,9 @@
them, producing the (hard, soft) signal SkillOpt's gate consumes.
Single-shot text replay by default. Tasks whose rule judge requires a tool
-call (gbrain's `tool_called`) are run through the backend's real tool loop
-(attempt_with_tools), so tool use is verified honestly rather than self-reported.
+call (gbrain's `tool_called`) use the backend's tool-aware path. Backends that
+cannot enforce that execution boundary fail explicitly rather than scoring a
+self-reported tool call.
"""
from __future__ import annotations
@@ -143,4 +144,3 @@ def multi_objective_reward(
if total_w <= 0:
return acc
return (w_acc * acc + w_tokens * tok_score + w_latency * lat_score) / total_w
-
diff --git a/skillopt_sleep/types.py b/skillopt_sleep/types.py
index 6cfa6239..d25e3f4c 100644
--- a/skillopt_sleep/types.py
+++ b/skillopt_sleep/types.py
@@ -17,8 +17,8 @@
class SessionDigest:
"""A normalized summary of one local agent session transcript.
- Produced by source-specific harvesters from Claude Code transcripts or
- Codex Desktop archived sessions.
+ Produced by source-specific harvesters from Claude Code transcripts, Codex
+ Desktop archived sessions, or Cursor Agent transcripts.
"""
session_id: str
diff --git a/tests/test_plugin_sync.py b/tests/test_plugin_sync.py
index f7850e26..6df09e1d 100644
--- a/tests/test_plugin_sync.py
+++ b/tests/test_plugin_sync.py
@@ -2,6 +2,7 @@
Run: python3 -m pytest tests/test_plugin_sync.py -v
"""
+import json
import os
import unittest
@@ -10,6 +11,7 @@
PLUGIN_SKILL_MDS = {
"claude-code": os.path.join(REPO, "plugins/claude-code/skills/skillopt-sleep/SKILL.md"),
"codex": os.path.join(REPO, "plugins/codex/skills/skillopt-sleep/SKILL.md"),
+ "cursor": os.path.join(REPO, "plugins/cursor/skills/skillopt-sleep/SKILL.md"),
"openclaw": os.path.join(REPO, "plugins/openclaw/SKILL.md"),
}
@@ -17,6 +19,14 @@
COPILOT_INSTRUCTIONS = os.path.join(REPO, "plugins/copilot/copilot-instructions.snippet.md")
CANONICAL_BACKENDS = {"mock", "claude", "codex", "copilot"}
+CURSOR_MANIFEST = os.path.join(REPO, "plugins/cursor/.cursor-plugin/plugin.json")
+CURSOR_MARKETPLACE = os.path.join(REPO, ".cursor-plugin/marketplace.json")
+CURSOR_COMMAND = os.path.join(REPO, "plugins/cursor/commands/skillopt-sleep.md")
+CURSOR_README = os.path.join(REPO, "plugins/cursor/README.md")
+CURSOR_INSTALL_SH = os.path.join(REPO, "plugins/cursor/install.sh")
+CURSOR_INSTALL_PS1 = os.path.join(REPO, "plugins/cursor/install.ps1")
+CURSOR_LICENSE = os.path.join(REPO, "plugins/cursor/LICENSE")
+OPENCLAW_RUNNER = os.path.join(REPO, "plugins/openclaw/run_sleep.py")
def _read(path):
@@ -27,6 +37,82 @@ def _read(path):
class TestPluginParity(unittest.TestCase):
+ def test_cursor_plugin_manifest_and_marketplace_registration(self):
+ with open(CURSOR_MANIFEST, encoding="utf-8") as f:
+ manifest = json.load(f)
+ with open(CURSOR_MARKETPLACE, encoding="utf-8") as f:
+ marketplace = json.load(f)
+
+ self.assertEqual(manifest["name"], "skillopt-sleep")
+ self.assertEqual(manifest["skills"], "./skills/")
+ self.assertEqual(manifest["commands"], "./commands/")
+ self.assertNotIn("hooks", manifest)
+ self.assertNotIn("mcpServers", manifest)
+ allowed_manifest_keys = {
+ "name", "displayName", "description", "version", "author",
+ "publisher", "homepage", "repository", "license", "logo",
+ "keywords", "category", "tags", "commands", "agents", "skills",
+ "rules", "hooks", "mcpServers",
+ }
+ self.assertEqual(set(manifest) - allowed_manifest_keys, set())
+ registered = next(
+ (plugin for plugin in marketplace["plugins"] if plugin.get("name") == "skillopt-sleep"),
+ None,
+ )
+ self.assertIsNotNone(registered)
+ self.assertEqual(registered["source"], "plugins/cursor")
+ self.assertEqual(registered["name"], manifest["name"])
+ self.assertTrue(os.path.isdir(os.path.join(REPO, registered["source"])))
+
+ def test_cursor_skill_has_frontmatter_target_and_cursor_guidance(self):
+ text = _read(PLUGIN_SKILL_MDS["cursor"])
+ self.assertTrue(text.startswith("---\n"))
+ self.assertIn("name: skillopt-sleep", text)
+ self.assertIn(".cursor/skills/skillopt-sleep-learned/SKILL.md", text)
+ self.assertIn("--source cursor", text)
+ self.assertIn("--backend cursor", text)
+
+ def test_cursor_command_is_thin_and_preserves_safety_defaults(self):
+ text = _read(CURSOR_COMMAND)
+ self.assertIn("$ARGUMENTS", text)
+ self.assertIn("use `status`", text)
+ self.assertIn("--source cursor", text)
+ self.assertIn("--scope invoked", text)
+ self.assertIn(".cursor/skills/skillopt-sleep-learned/SKILL.md", text)
+ self.assertIn("`mock` backend", text)
+ self.assertNotIn("--auto-adopt", text)
+
+ def test_cursor_installers_package_command_skill_readme_and_license(self):
+ for installer in (CURSOR_INSTALL_SH, CURSOR_INSTALL_PS1):
+ text = _read(installer)
+ for filename in (
+ "plugin.json",
+ "commands/skillopt-sleep.md" if installer.endswith(".sh") else "commands\\skillopt-sleep.md",
+ "skills/skillopt-sleep/SKILL.md" if installer.endswith(".sh") else "skills\\skillopt-sleep\\SKILL.md",
+ "README.md",
+ "LICENSE",
+ ):
+ self.assertIn(filename, text, f"{installer} does not package {filename}")
+
+ self.assertEqual(_read(CURSOR_LICENSE), _read(os.path.join(REPO, "LICENSE")))
+
+ def test_cursor_docs_keep_scheduling_explicit_and_target_relative(self):
+ for path in (CURSOR_README, PLUGIN_SKILL_MDS["cursor"]):
+ text = _read(path)
+ self.assertIn('"target_skill_path": ".cursor/skills/', text)
+ self.assertIn("no session-end hook", text.lower())
+ self.assertIn("`tool_called`", text)
+ self.assertIn("temporarily disabled", text.lower())
+ self.assertIn("before agent mode", text.lower())
+
+ def test_openclaw_wrapper_matches_shared_backend_signature(self):
+ text = _read(OPENCLAW_RUNNER)
+ self.assertIn('cursor_path=""', text)
+ self.assertIn('project_dir=""', text)
+ self.assertIn("cursor_path=cursor_path", text)
+ self.assertIn("project_dir=project_dir", text)
+ self.assertNotIn("**kwargs", text)
+
def test_all_skill_mds_mention_all_backends(self):
for name, path in PLUGIN_SKILL_MDS.items():
text = _read(path)
diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py
index 36cb88b9..0ddc6ec2 100644
--- a/tests/test_sleep_engine.py
+++ b/tests/test_sleep_engine.py
@@ -136,6 +136,355 @@ def test_digest_codex_archived_session_sanitizes_and_skips_meta(self):
self.assertNotIn("raw args should not copy", joined)
self.assertNotIn("raw output should not copy", joined)
+ def test_digest_cursor_transcript_redacts_and_keeps_only_message_text_and_tool_names(self):
+ from skillopt_sleep.harvest_cursor import digest_cursor_transcript
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "cursor-session.jsonl")
+ self._write_jsonl(path, [
+ {
+ "role": "user",
+ "message": {
+ "content": [{
+ "type": "text",
+ "text": (
+ "never-copy-attachment-metadata\n"
+ "\n"
+ "Deploy with sk-1234567890abcdef and token=local-secret\n"
+ ""
+ ),
+ }],
+ },
+ },
+ {
+ "role": "assistant",
+ "message": {
+ "content": [{
+ "type": "tool_use",
+ "name": "shell.execute",
+ "input": {"token": "never-copy-tool-arguments"},
+ }],
+ },
+ },
+ {
+ "role": "assistant",
+ "message": {
+ "content": [
+ {"type": "text", "text": "Deployment finished."},
+ {
+ "type": "tool_use",
+ "name": "read_file",
+ "input": {"path": "never-copy-tool-arguments"},
+ },
+ ],
+ },
+ },
+ {"type": "tool_result", "output": "never-copy-tool-output"},
+ {"type": "turn_ended", "status": "error"},
+ ])
+ with open(path, "a", encoding="utf-8") as f:
+ f.write("null\n")
+ f.write("[]\n")
+ f.write('"non-object"\n')
+ f.write("{malformed jsonl record\\n")
+
+ digest = digest_cursor_transcript(path, project="/repo/Cursor Project")
+
+ self.assertIsNotNone(digest)
+ joined = "\n".join(digest.user_prompts + digest.assistant_finals)
+ self.assertEqual(digest.project, "/repo/Cursor Project")
+ self.assertEqual(len(digest.user_prompts), 1)
+ self.assertIn("[REDACTED_OPENAI_KEY]", joined)
+ self.assertIn("token=[REDACTED]", joined)
+ self.assertEqual(digest.tools_used, ["shell.execute", "read_file"])
+ self.assertIn("neg:cursor_turn_error", digest.feedback_signals)
+ self.assertNotIn("never-copy-tool-arguments", joined)
+ self.assertNotIn("never-copy-tool-output", joined)
+ self.assertNotIn("never-copy-attachment-metadata", joined)
+
+ def test_harvest_cursor_scopes_orders_filters_mtime_and_skips_replays(self):
+ from skillopt_sleep.__main__ import _cfg_from_args
+ from skillopt_sleep.harvest_cursor import (
+ CURSOR_REPLAY_SENTINEL,
+ cursor_project_slug,
+ harvest_cursor,
+ )
+ from skillopt_sleep.harvest_sources import harvest_for_config
+
+ def write_cursor_session(cursor_home, project, session_id, prompt, mtime, extra_prompt=""):
+ project_dir = os.path.join(
+ cursor_home,
+ "projects",
+ cursor_project_slug(project),
+ )
+ path = os.path.join(
+ project_dir,
+ "agent-transcripts",
+ session_id,
+ f"{session_id}.jsonl",
+ )
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(os.path.join(project_dir, ".workspace-trusted"), "w", encoding="utf-8") as f:
+ json.dump({"workspacePath": project}, f)
+ records = [
+ {
+ "role": "user",
+ "message": {
+ "content": [{
+ "type": "text",
+ "text": f"\n{prompt}\n",
+ }],
+ },
+ },
+ {
+ "role": "assistant",
+ "message": {"content": [{"type": "text", "text": "done"}]},
+ },
+ ]
+ if extra_prompt:
+ records.extend([
+ {
+ "role": "user",
+ "message": {
+ "content": [{
+ "type": "text",
+ "text": f"\n{extra_prompt}\n",
+ }],
+ },
+ },
+ {
+ "role": "assistant",
+ "message": {"content": [{"type": "text", "text": "done again"}]},
+ },
+ ])
+ self._write_jsonl(path, records)
+ os.utime(path, (mtime, mtime))
+ return path
+
+ with tempfile.TemporaryDirectory() as tmp:
+ cursor_home = os.path.join(tmp, ".cursor")
+ project = os.path.join(tmp, "project with spaces")
+ other_project = os.path.join(tmp, "other project")
+ old_time = 1_700_000_000
+ new_time = old_time + 3_600
+ main_path = write_cursor_session(
+ cursor_home,
+ project,
+ "older",
+ "fix the first issue",
+ old_time,
+ )
+ subagent_path = os.path.join(os.path.dirname(main_path), "subagents", "worker.jsonl")
+ os.makedirs(os.path.dirname(subagent_path), exist_ok=True)
+ self._write_jsonl(subagent_path, [
+ {"role": "user", "message": {"content": "machine-generated subagent task"}},
+ {"role": "assistant", "message": {"content": "subagent result"}},
+ ])
+ write_cursor_session(cursor_home, other_project, "newer", "fix the second issue", new_time)
+ write_cursor_session(
+ cursor_home,
+ other_project,
+ "generated-replay",
+ CURSOR_REPLAY_SENTINEL + "\n## CURRENT SKILL",
+ new_time + 1,
+ extra_prompt="continue the internal replay",
+ )
+
+ invoked = harvest_cursor(
+ os.path.join(cursor_home, "projects"),
+ scope="invoked",
+ invoked_project=os.path.join(project, "src", "package"),
+ )
+ all_digests = harvest_cursor(
+ os.path.join(cursor_home, "projects"),
+ scope="all",
+ since_iso="2023-11-14T23:00:00Z",
+ limit=1,
+ )
+
+ Args = type("Args", (), {
+ "project": project,
+ "scope": "",
+ "backend": "cursor",
+ "model": "",
+ "codex_path": "",
+ "cursor_path": "",
+ "claude_home": "",
+ "codex_home": "",
+ "cursor_home": cursor_home,
+ "source": "cursor",
+ "lookback_hours": 0,
+ "edit_budget": 0,
+ "max_sessions": 0,
+ "max_tasks": 0,
+ "target_skill_path": "",
+ "preferences": "",
+ "progress": False,
+ "auto_adopt": False,
+ })
+ cfg = _cfg_from_args(Args())
+ configured = harvest_for_config(cfg)
+
+ self.assertEqual([d.session_id for d in invoked], ["older"])
+ self.assertEqual([d.session_id for d in all_digests], ["newer"])
+ self.assertEqual(invoked[0].project, project)
+ self.assertEqual(all_digests[0].project, other_project)
+ self.assertEqual([d.session_id for d in configured], ["older"])
+ self.assertEqual(cfg.get("transcript_source"), "cursor")
+ self.assertEqual(cfg.get("backend"), "cursor")
+
+ def test_harvest_cursor_prefers_longest_workspace_and_falls_back_to_slug(self):
+ from skillopt_sleep.harvest_cursor import cursor_project_slug, harvest_cursor
+
+ def write_session(projects_dir, storage_name, workspace, session_id):
+ project_dir = os.path.join(projects_dir, storage_name)
+ path = os.path.join(project_dir, "agent-transcripts", session_id, f"{session_id}.jsonl")
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ self._write_jsonl(path, [
+ {"role": "user", "message": {"content": "please fix this project"}},
+ {"role": "assistant", "message": {"content": "fixed"}},
+ ])
+ if workspace is not None:
+ with open(os.path.join(project_dir, ".workspace-trusted"), "w", encoding="utf-8") as f:
+ json.dump(workspace, f)
+ return path
+
+ with tempfile.TemporaryDirectory() as tmp:
+ projects_dir = os.path.join(tmp, ".cursor", "projects")
+ parent = os.path.join(tmp, "repo")
+ nested = os.path.join(parent, "packages", "app")
+ write_session(projects_dir, "parent-store", {"workspacePath": parent}, "parent")
+ write_session(projects_dir, "nested-store", {"workspacePath": nested}, "nested")
+ fallback = os.path.join(tmp, "fallback")
+ write_session(
+ projects_dir,
+ cursor_project_slug(fallback),
+ ["invalid metadata shape"],
+ "fallback",
+ )
+ metadata_free = os.path.join(tmp, "metadata-free")
+ write_session(
+ projects_dir,
+ cursor_project_slug(metadata_free),
+ None,
+ "metadata-free",
+ )
+ mixed_parent = os.path.join(tmp, "mixed-parent")
+ mixed_nested = os.path.join(mixed_parent, "nested")
+ write_session(projects_dir, "mixed-parent-store", {"workspacePath": mixed_parent}, "mixed-parent")
+ write_session(
+ projects_dir,
+ cursor_project_slug(mixed_nested),
+ None,
+ "mixed-nested",
+ )
+
+ nested_digests = harvest_cursor(
+ projects_dir,
+ scope="invoked",
+ invoked_project=os.path.join(nested, "src"),
+ )
+ fallback_digests = harvest_cursor(
+ projects_dir,
+ scope="invoked",
+ invoked_project=fallback,
+ )
+ metadata_free_digests = harvest_cursor(
+ projects_dir,
+ scope="invoked",
+ invoked_project=os.path.join(metadata_free, "packages", "app"),
+ )
+ mixed_digests = harvest_cursor(
+ projects_dir,
+ scope="invoked",
+ invoked_project=mixed_nested,
+ )
+
+ self.assertEqual([digest.session_id for digest in nested_digests], ["nested"])
+ self.assertEqual(nested_digests[0].project, nested)
+ self.assertEqual([digest.session_id for digest in fallback_digests], ["fallback"])
+ self.assertEqual(fallback_digests[0].project, fallback)
+ self.assertEqual(
+ [digest.session_id for digest in metadata_free_digests],
+ ["metadata-free"],
+ )
+ self.assertEqual(metadata_free_digests[0].project, metadata_free)
+ self.assertEqual([digest.session_id for digest in mixed_digests], ["mixed-nested"])
+ self.assertEqual(mixed_digests[0].project, mixed_nested)
+
+ def test_harvest_cursor_uses_numeric_mtime_for_aware_and_local_cutoffs(self):
+ from datetime import datetime, timedelta, timezone
+
+ from skillopt_sleep.harvest_cursor import cursor_project_slug, harvest_cursor
+
+ with tempfile.TemporaryDirectory() as tmp:
+ project = os.path.join(tmp, "project")
+ project_dir = os.path.join(tmp, ".cursor", "projects", cursor_project_slug(project))
+ os.makedirs(project_dir)
+ with open(os.path.join(project_dir, ".workspace-trusted"), "w", encoding="utf-8") as f:
+ json.dump({"workspacePath": project}, f)
+
+ cutoff = 1_700_000_000
+ for session_id, modified in (("before", cutoff - 1), ("equal", cutoff), ("after", cutoff + 1)):
+ path = os.path.join(
+ project_dir,
+ "agent-transcripts",
+ session_id,
+ f"{session_id}.jsonl",
+ )
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ self._write_jsonl(path, [
+ {"role": "user", "message": {"content": f"task {session_id}"}},
+ {"role": "assistant", "message": {"content": "done"}},
+ ])
+ os.utime(path, (modified, modified))
+
+ aware = datetime.fromtimestamp(cutoff, timezone(timedelta(hours=5))).isoformat()
+ local = datetime.fromtimestamp(cutoff).replace(microsecond=0).isoformat()
+ aware_result = harvest_cursor(projects_dir=os.path.dirname(project_dir), since_iso=aware)
+ local_result = harvest_cursor(projects_dir=os.path.dirname(project_dir), since_iso=local)
+
+ self.assertEqual([digest.session_id for digest in aware_result], ["after"])
+ self.assertEqual([digest.session_id for digest in local_result], ["after"])
+
+ def test_harvest_cursor_filters_only_exact_internal_replay_sentinel(self):
+ from skillopt_sleep.harvest_cursor import CURSOR_REPLAY_SENTINEL, cursor_project_slug, harvest_cursor
+
+ with tempfile.TemporaryDirectory() as tmp:
+ project = os.path.join(tmp, "project")
+ project_dir = os.path.join(tmp, ".cursor", "projects", cursor_project_slug(project))
+ for session_id, prompt in (
+ ("internal", CURSOR_REPLAY_SENTINEL + "\nrun replay"),
+ ("real", f"Please explain what {CURSOR_REPLAY_SENTINEL} means"),
+ ("grader", "You are a strict grader helping me review this response"),
+ ("skill", "Please explain the ## CURRENT SKILL section"),
+ ):
+ path = os.path.join(project_dir, "agent-transcripts", session_id, f"{session_id}.jsonl")
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ self._write_jsonl(path, [
+ {"role": "user", "message": {"content": prompt}},
+ {"role": "assistant", "message": {"content": "answer"}},
+ ])
+
+ digests = harvest_cursor(os.path.join(tmp, ".cursor", "projects"), scope="all")
+
+ self.assertEqual(
+ sorted(digest.session_id for digest in digests),
+ ["grader", "real", "skill"],
+ )
+
+ def test_auto_source_keeps_existing_codex_then_claude_precedence(self):
+ from skillopt_sleep.harvest_sources import harvest_for_config
+
+ cfg = load_config(transcript_source="auto", invoked_project="/repo/project")
+ expected = [SessionDigest(session_id="claude-session", project="/repo/project")]
+ with mock.patch("skillopt_sleep.harvest_sources.harvest_codex", return_value=[]), \
+ mock.patch("skillopt_sleep.harvest_sources.harvest", return_value=expected), \
+ mock.patch("skillopt_sleep.harvest_sources.harvest_cursor") as cursor_harvest:
+ self.assertEqual(harvest_for_config(cfg), expected)
+
+ cursor_harvest.assert_not_called()
+
def test_harvest_codex_filters_project_and_cli_source(self):
from skillopt_sleep.__main__ import _cfg_from_args
from skillopt_sleep.harvest_sources import harvest_for_config
@@ -478,6 +827,18 @@ def test_mine_oversamples_before_target_filtering(self):
"resolve a local Git conflict",
})
+ def test_cursor_miner_failure_is_not_swallowed(self):
+ from skillopt_sleep.backend import CursorBackendError
+
+ def failed_miner(_digests):
+ raise CursorBackendError("Cursor Agent authentication failed")
+
+ with self.assertRaises(CursorBackendError):
+ mine(
+ [self._digest(["configure an MCP server"], ["neg:failed"])],
+ llm_miner=failed_miner,
+ )
+
class TestConsolidateGate(unittest.TestCase):
def test_accepts_helpful_rejects_harmful(self):
@@ -1113,6 +1474,384 @@ def test_attempt_with_tools_honest_detection(self):
shutil.rmtree(stub_dir, ignore_errors=True)
+class TestCursorBackend(unittest.TestCase):
+ """Pure-logic tests for CursorCliBackend without a Cursor login."""
+
+ def test_alias_and_environment_resolution(self):
+ from skillopt_sleep.backend import CursorCliBackend, get_backend, resolve_cursor_path
+
+ for name in ("cursor", "cursor_agent", "cursor_cli"):
+ self.assertIsInstance(get_backend(name), CursorCliBackend, name)
+ with mock.patch.dict(os.environ, {
+ "SKILLOPT_SLEEP_CURSOR_PATH": "/tmp/cursor-agent",
+ "SKILLOPT_SLEEP_CURSOR_MODEL": "cursor-small",
+ }, clear=False):
+ self.assertEqual(resolve_cursor_path(), "/tmp/cursor-agent")
+ self.assertEqual(CursorCliBackend().model, "cursor-small")
+
+ def test_cursor_path_overrides_expand_user_home(self):
+ from skillopt_sleep.__main__ import _cfg_from_args
+ from skillopt_sleep.backend import resolve_cursor_path
+
+ Args = type("Args", (), {
+ "project": "",
+ "scope": "",
+ "backend": "",
+ "model": "",
+ "codex_path": "",
+ "cursor_path": "~/.local/bin/cursor-agent",
+ "claude_home": "",
+ "codex_home": "",
+ "cursor_home": "~/.cursor-custom",
+ "source": "",
+ "lookback_hours": None,
+ "edit_budget": 0,
+ "max_sessions": 0,
+ "max_tasks": 0,
+ "target_skill_path": "",
+ "preferences": "",
+ "progress": False,
+ "auto_adopt": False,
+ })
+
+ cfg = _cfg_from_args(Args())
+ self.assertEqual(
+ cfg.get("cursor_path"),
+ os.path.abspath(os.path.expanduser("~/.local/bin/cursor-agent")),
+ )
+ self.assertEqual(
+ cfg.cursor_projects_dir,
+ os.path.join(os.path.expanduser("~/.cursor-custom"), "projects"),
+ )
+
+ direct_cfg = load_config(
+ cursor_home="~/.cursor-config",
+ cursor_path="~/.cursor-config/bin/cursor-agent",
+ )
+ self.assertEqual(
+ direct_cfg.cursor_projects_dir,
+ os.path.join(os.path.expanduser("~/.cursor-config"), "projects"),
+ )
+ self.assertEqual(
+ resolve_cursor_path(direct_cfg.get("cursor_path")),
+ os.path.expanduser("~/.cursor-config/bin/cursor-agent"),
+ )
+ with mock.patch.dict(
+ os.environ,
+ {"SKILLOPT_SLEEP_CURSOR_PATH": "~/.cursor-env/bin/cursor-agent"},
+ clear=False,
+ ):
+ self.assertEqual(
+ resolve_cursor_path(),
+ os.path.expanduser("~/.cursor-env/bin/cursor-agent"),
+ )
+
+ def test_read_only_call_uses_stdin_ask_mode_and_terminal_result(self):
+ from skillopt_sleep.backend import CursorCliBackend
+ from skillopt_sleep.harvest_cursor import CURSOR_REPLAY_SENTINEL
+
+ calls = []
+ runtime_configs = []
+
+ def fake_run(cmd, **kwargs):
+ calls.append((cmd, kwargs))
+ config_dir = kwargs["env"]["CURSOR_CONFIG_DIR"]
+ data_dir = kwargs["env"]["CURSOR_DATA_DIR"]
+ with open(os.path.join(config_dir, "cli-config.json"), encoding="utf-8") as f:
+ runtime_configs.append(json.load(f))
+ self.assertTrue(os.path.isdir(data_dir))
+
+ class Proc:
+ returncode = 0
+ stdout = (
+ '{"type":"message","result":"intermediate"}\n'
+ '{"type":"result","subtype":"success","is_error":false,'
+ '"result":"final answer"}\n'
+ )
+ stderr = ""
+
+ return Proc()
+
+ backend = CursorCliBackend(cursor_path="cursor-agent-test", model="cursor-model")
+ with mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=fake_run):
+ self.assertEqual(backend._call("solve this"), "final answer")
+
+ cmd, kwargs = calls[0]
+ self.assertEqual(cmd[0], "cursor-agent-test")
+ self.assertIn("-p", cmd)
+ self.assertEqual(cmd[cmd.index("--output-format") + 1], "json")
+ self.assertEqual(cmd[cmd.index("--mode") + 1], "ask")
+ self.assertIn("--trust", cmd)
+ self.assertEqual(cmd[cmd.index("--workspace") + 1], kwargs["cwd"])
+ self.assertTrue(os.path.basename(kwargs["cwd"]).startswith("skillopt_sleep_cursor_"))
+ self.assertNotIn("--force", cmd)
+ self.assertNotIn("--sandbox", cmd)
+ self.assertEqual(cmd[cmd.index("--model") + 1], "cursor-model")
+ self.assertTrue(kwargs["input"].startswith(CURSOR_REPLAY_SENTINEL + "\n\n"))
+ self.assertTrue(kwargs["input"].endswith("solve this"))
+ self.assertNotEqual(kwargs["env"]["CURSOR_CONFIG_DIR"], os.path.expanduser("~/.cursor"))
+ self.assertEqual(runtime_configs[0]["approvalMode"], "allowlist")
+ self.assertEqual(runtime_configs[0]["permissions"]["allow"], [])
+ self.assertEqual(
+ runtime_configs[0]["permissions"]["deny"],
+ ["Read(**)", "Write(**)", "Mcp(*:*)"],
+ )
+ self.assertEqual(runtime_configs[0]["sandbox"]["mode"], "disabled")
+ self.assertFalse(os.path.exists(os.path.dirname(kwargs["env"]["CURSOR_CONFIG_DIR"])))
+ self.assertEqual(backend.last_call_error, "")
+ self.assertEqual(
+ CursorCliBackend._parse_json_response('{"type":"message","result":"not terminal"}'),
+ "",
+ )
+
+ def test_nonzero_and_error_results_fail_once_with_redacted_diagnostics(self):
+ from skillopt_sleep.backend import CursorBackendError, CursorCliBackend
+
+ backend = CursorCliBackend(cursor_path="cursor-agent-test", timeout=7)
+
+ class BadProc:
+ returncode = 9
+ stdout = "not-json"
+ stderr = "Authorization: Bearer cursor-secret-value"
+
+ with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=BadProc()) as run:
+ with self.assertRaises(CursorBackendError):
+ backend._call("solve this")
+ self.assertEqual(run.call_count, 1)
+ self.assertIn("exited 9", backend.last_call_error)
+ self.assertIn("[REDACTED]", backend.last_call_error)
+ self.assertNotIn("cursor-secret-value", backend.last_call_error)
+
+ class ErrorProc:
+ returncode = 0
+ stdout = '{"type":"result","is_error":true,"result":"api_key=cursor-secret"}'
+ stderr = ""
+
+ with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=ErrorProc()) as run:
+ with self.assertRaises(CursorBackendError):
+ backend._call("solve this")
+ self.assertEqual(run.call_count, 1)
+ self.assertIn("error result", backend.last_call_error)
+ self.assertIn("[REDACTED]", backend.last_call_error)
+ self.assertNotIn("cursor-secret", backend.last_call_error)
+
+ with mock.patch(
+ "skillopt_sleep.backend.subprocess.run",
+ side_effect=OSError("missing cursor-agent"),
+ ) as run:
+ with self.assertRaises(CursorBackendError):
+ backend._call("solve this")
+ self.assertEqual(run.call_count, 1)
+ self.assertIn("spawn failed", backend.last_call_error)
+
+ def test_read_only_timeout_and_malformed_output_retry_once(self):
+ import subprocess
+
+ from skillopt_sleep.backend import CursorBackendError, CursorCliBackend
+
+ backend = CursorCliBackend(cursor_path="cursor-agent-test", timeout=7)
+
+ class GoodProc:
+ returncode = 0
+ stdout = '{"type":"result","is_error":false,"result":"recovered"}'
+ stderr = ""
+
+ with mock.patch(
+ "skillopt_sleep.backend.subprocess.run",
+ side_effect=[subprocess.TimeoutExpired(["cursor-agent-test"], 7), GoodProc()],
+ ) as run:
+ self.assertEqual(backend._call("solve this"), "recovered")
+ self.assertEqual(run.call_count, 2)
+ self.assertEqual(backend.last_call_error, "")
+
+ class MalformedProc:
+ returncode = 0
+ stdout = "still not json"
+ stderr = ""
+
+ with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=MalformedProc()) as run:
+ with self.assertRaises(CursorBackendError):
+ backend._call("solve this")
+ self.assertEqual(run.call_count, 2)
+ self.assertIn("no usable JSON response", backend.last_call_error)
+
+ class AuthProc:
+ returncode = 0
+ stdout = ""
+ stderr = "Not authenticated. Please log in with token=cursor-secret"
+
+ with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=AuthProc()) as run:
+ with self.assertRaises(CursorBackendError):
+ backend._call("solve this")
+ self.assertEqual(run.call_count, 1)
+ self.assertIn("authentication failed", backend.last_call_error)
+ self.assertNotIn("cursor-secret", backend.last_call_error)
+
+ class ConfigProc:
+ returncode = 0
+ stdout = ""
+ stderr = "Unsupported model: cursor-unknown"
+
+ with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=ConfigProc()) as run:
+ with self.assertRaises(CursorBackendError):
+ backend._call("solve this")
+ self.assertEqual(run.call_count, 1)
+ self.assertIn("configuration failed", backend.last_call_error)
+
+ def test_failed_cursor_call_is_not_cached(self):
+ from skillopt_sleep.backend import CursorBackendError, CursorCliBackend
+
+ class BadProc:
+ returncode = 1
+ stdout = ""
+ stderr = "not authenticated"
+
+ class GoodProc:
+ returncode = 0
+ stdout = '{"type":"result","is_error":false,"result":"answer"}'
+ stderr = ""
+
+ backend = CursorCliBackend(cursor_path="cursor-agent-test")
+ task = TaskRecord(id="cache", project="/p", intent="answer this")
+ with mock.patch(
+ "skillopt_sleep.backend.subprocess.run",
+ side_effect=[BadProc(), GoodProc()],
+ ) as run:
+ with self.assertRaises(CursorBackendError):
+ backend.attempt(task, skill="", memory="")
+ self.assertEqual(backend.attempt(task, skill="", memory=""), "answer")
+ self.assertEqual(backend.attempt(task, skill="", memory=""), "answer")
+
+ self.assertEqual(run.call_count, 2)
+
+ def test_tool_aware_replay_fails_before_cursor_subprocess(self):
+ from skillopt_sleep.backend import CursorBackendError, CursorCliBackend
+
+ backend = CursorCliBackend(cursor_path="cursor-agent-test")
+ task = TaskRecord(id="cursor-tools", project="/p", intent="search")
+ with mock.patch("skillopt_sleep.backend.subprocess.run") as run:
+ with self.assertRaisesRegex(
+ CursorBackendError,
+ "Cursor tool-aware replay is temporarily disabled",
+ ):
+ backend.attempt_with_tools(task, skill="", memory="", tools=["search"])
+ run.assert_not_called()
+ self.assertIn("temporarily disabled", backend.last_call_error)
+ self.assertEqual(backend._cache, {})
+
+ def test_tool_aware_cli_run_fails_without_writes_or_checkpoint(self):
+ import contextlib
+ import io
+
+ from skillopt_sleep.__main__ import main
+ from skillopt_sleep.backend import CursorCliBackend
+ from skillopt_sleep.tasks_file import make_tasks_payload, write_tasks_file
+
+ with tempfile.TemporaryDirectory() as tmp:
+ project = os.path.join(tmp, "project")
+ claude_home = os.path.join(tmp, ".claude")
+ target = os.path.join(
+ project,
+ ".cursor",
+ "skills",
+ "skillopt-sleep-learned",
+ "SKILL.md",
+ )
+ os.makedirs(project)
+ task = TaskRecord(
+ id="cursor-tool-task",
+ project=project,
+ intent="Search before answering",
+ reference_kind="rule",
+ judge={"checks": [{"op": "tool_called", "arg": "search"}]},
+ split="val",
+ )
+ payload = make_tasks_payload(
+ [task],
+ project=project,
+ transcript_source="cursor",
+ target_skill_path=target,
+ )
+ payload["reviewed"] = True
+ tasks_path = write_tasks_file(os.path.join(tmp, "tasks.json"), payload)
+ backend = CursorCliBackend(cursor_path="cursor-agent-test")
+ stderr = io.StringIO()
+
+ with mock.patch(
+ "skillopt_sleep.cycle.get_backend",
+ return_value=backend,
+ ):
+ with mock.patch("skillopt_sleep.backend.subprocess.run") as run:
+ with contextlib.redirect_stderr(stderr):
+ rc = main([
+ "run",
+ "--project", project,
+ "--claude-home", claude_home,
+ "--backend", "cursor",
+ "--tasks-file", tasks_path,
+ "--target-skill-path", target,
+ "--auto-adopt",
+ ])
+
+ self.assertEqual(rc, 1)
+ self.assertIn("Cursor tool-aware replay is temporarily disabled", stderr.getvalue())
+ run.assert_not_called()
+ self.assertEqual(backend._cache, {})
+ self.assertFalse(os.path.exists(os.path.join(tmp, ".skillopt-sleep")))
+ self.assertFalse(os.path.exists(os.path.join(project, ".skillopt-sleep")))
+ self.assertFalse(os.path.exists(target))
+
+ def test_cursor_failure_aborts_without_state_or_staging_and_cli_returns_nonzero(self):
+ import contextlib
+ import io
+
+ from skillopt_sleep.__main__ import main
+ from skillopt_sleep.backend import CursorBackendError, CursorCliBackend
+
+ with tempfile.TemporaryDirectory() as tmp:
+ project = os.path.join(tmp, "project")
+ os.makedirs(project)
+ cfg = load_config(
+ backend="cursor",
+ invoked_project=project,
+ projects="invoked",
+ claude_home=os.path.join(tmp, ".claude"),
+ target_skill_path=".cursor/skills/skillopt-sleep-learned/SKILL.md",
+ )
+ backend = CursorCliBackend(cursor_path="cursor-agent-test")
+ task = TaskRecord(
+ id="failure",
+ project=project,
+ intent="answer this",
+ reference_kind="exact",
+ reference="answer",
+ split="val",
+ )
+ with mock.patch.object(
+ backend,
+ "_call",
+ side_effect=CursorBackendError("Cursor Agent exited 1: token [REDACTED]"),
+ ):
+ with self.assertRaises(CursorBackendError):
+ run_sleep_cycle(cfg, seed_tasks=[task], backend=backend)
+
+ self.assertFalse(os.path.exists(cfg.state_path))
+ self.assertFalse(os.path.exists(os.path.join(project, ".skillopt-sleep")))
+ self.assertFalse(os.path.exists(cfg.managed_skill_path()))
+
+ stderr = io.StringIO()
+ with mock.patch(
+ "skillopt_sleep.__main__.run_sleep_cycle",
+ side_effect=CursorBackendError("Cursor Agent exited 1: token=cursor-secret"),
+ ), contextlib.redirect_stderr(stderr):
+ rc = main(["dry-run", "--project", project, "--backend", "cursor"])
+
+ self.assertEqual(rc, 1)
+ self.assertIn("Cursor backend failed", stderr.getvalue())
+ self.assertIn("[REDACTED]", stderr.getvalue())
+ self.assertNotIn("cursor-secret", stderr.getvalue())
+
+
class TestClaudeCliBackendBare(unittest.TestCase):
"""Issue #68: --bare must be conditional on ANTHROPIC_API_KEY."""