From cd5ca482262d53a7d713d7e7328adedc02a51c1e Mon Sep 17 00:00:00 2001
From: James Davies See the installation guide
for platform notes and dependency boundaries. 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.
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.
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
@@ -467,6 +478,7 @@
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.
cursor-agent to Cursor's selected model provider.--auto-adopt only when you have an independent rollback and
validation process.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. Tool-validation shims return synthetic output rather
+ than running project CLIs. 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
@@ -502,6 +508,7 @@
dream_rolloutsdream_factorrecall_kreplay_modecursor-agent to Cursor's selected model provider.
+ 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.--auto-adopt only when you have an independent rollback and
validation process.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. Tool-validation shims return synthetic output rather
- than running project CLIs. This validates textual guidance, not end-to-end
- repository, browser, service, or filesystem workflows.
--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
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index 2d1db0c2..764cd2e2 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -141,14 +141,13 @@ 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.
-Replays that validate tool use run in another isolated temporary workspace with
-an isolated Cursor config. Agent-mode sandboxing is disabled so the local
-headless configuration allowlists only generated shims. The shims record calls
-and return synthetic canned output; they do not invoke same-named project tools.
-These calls do not use `--force` or automatic MCP approval. Organization-enforced
-Cursor policies still apply. 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.
+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
diff --git a/docs/sleep/README.md b/docs/sleep/README.md
index 8c7d8707..6e4d7856 100644
--- a/docs/sleep/README.md
+++ b/docs/sleep/README.md
@@ -38,8 +38,8 @@ experience → long-term competence).
> 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. Tool-validated tasks use
-> synthetic local shims in another isolated workspace, not real project tools.
+> 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.
@@ -104,8 +104,12 @@ 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. Generated tool shims return
-synthetic output and do not run project commands.
+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
diff --git a/plugins/README.md b/plugins/README.md
index 2d4b5bfc..61d222c9 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -75,10 +75,9 @@ optimization.
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. Tool-validated tasks use
- another isolated workspace with only synthetic generated shims allowlisted;
- those shims record calls and return canned output rather than invoking project
- tools. Cursor and the model provider selected by Cursor can receive the
+ 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
diff --git a/plugins/cursor/README.md b/plugins/cursor/README.md
index 37c37817..498fd36c 100644
--- a/plugins/cursor/README.md
+++ b/plugins/cursor/README.md
@@ -158,12 +158,11 @@ 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.
-Tool-validated replays also use an isolated temporary workspace and Cursor
-config. They expose only generated local shims that record a call and return
-synthetic canned output; they do not invoke similarly named project commands.
-Agent-mode sandboxing is disabled so the local headless configuration can
-allowlist only those shims. The backend does not use `--force` or automatic MCP
-approval, and organization-enforced Cursor policies still apply.
+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
diff --git a/plugins/cursor/skills/skillopt-sleep/SKILL.md b/plugins/cursor/skills/skillopt-sleep/SKILL.md
index 1e10cbf1..7caa8fc3 100644
--- a/plugins/cursor/skills/skillopt-sleep/SKILL.md
+++ b/plugins/cursor/skills/skillopt-sleep/SKILL.md
@@ -126,14 +126,13 @@ 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.
-A tool-validated replay uses another isolated temporary workspace and Cursor
-config. Its generated local shims record calls and return synthetic output; they
-do not run project tools with the same names. Agent-mode sandboxing is disabled
-so the local headless configuration allowlists only those shims. It does not use
-`--force` or automatic MCP approval. Organization-enforced Cursor policies still
-apply. Do not claim that repository- or tool-dependent behavior was validated,
-and do not weaken these boundaries to make a replay pass. The current engine
-does not implement a fresh-worktree replay for Cursor.
+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,
diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py
index 78ae14fa..dff640d3 100644
--- a/skillopt_sleep/backend.py
+++ b/skillopt_sleep/backend.py
@@ -1232,19 +1232,11 @@ 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; the explicit tool-rollout path switches
- to Agent mode with an isolated Cursor config and an explicit local-shim
- allowlist so tool-use judges can observe genuine calls without exposing the
- user's project or inheriting user-level Cursor permissions.
+ 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"
- _TOOL_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$")
- _WINDOWS_RESERVED = {
- "aux", "con", "nul", "prn",
- *(f"com{number}" for number in range(1, 10)),
- *(f"lpt{number}" for number in range(1, 10)),
- }
_AUTH_ERROR_MARKERS = (
"not authenticated",
"authentication required",
@@ -1265,13 +1257,13 @@ class CursorCliBackend(CliBackend):
"not available for your account",
"invalid configuration",
)
- _REPLAY_DENY = ["Read(**)", "Write(**)", "Mcp(*:*)"]
+ _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, *, read_only: bool) -> List[str]:
+ def _command(self, workspace: str) -> List[str]:
cmd = [
self.cursor_path,
"-p",
@@ -1280,14 +1272,9 @@ def _command(self, workspace: str, *, read_only: bool) -> List[str]:
"--trust",
"--workspace",
workspace,
+ "--mode",
+ "ask",
]
- if read_only:
- cmd += ["--mode", "ask"]
- else:
- # Sandbox mode can auto-run commands beyond the project allowlist.
- # With sandboxing disabled, headless allowlist mode keeps this
- # backend's permissions limited to the validated local shims.
- cmd += ["--sandbox", "disabled"]
if self.model:
cmd += ["--model", self.model]
return cmd
@@ -1325,42 +1312,6 @@ def _error(self, message: str, *, retryable: bool = False) -> CursorBackendError
logging.getLogger("skillopt_sleep").warning("Cursor Agent call failed: %s", self.last_call_error)
return CursorBackendError(self.last_call_error, retryable=retryable)
- @classmethod
- def _validated_tool_names(cls, tools: List[str]) -> List[str]:
- names = tools or ["search"]
- validated: List[str] = []
- seen = set()
- for name in names:
- if not isinstance(name, str) or not cls._TOOL_NAME_RE.fullmatch(name):
- raise ValueError(f"unsafe Cursor replay tool name: {name!r}")
- if os.path.isabs(name) or "/" in name or "\\" in name or name in {".", ".."}:
- raise ValueError(f"unsafe Cursor replay tool name: {name!r}")
- if name.split(".", 1)[0].casefold() in cls._WINDOWS_RESERVED:
- raise ValueError(f"reserved Cursor replay tool name: {name!r}")
- folded = name.casefold()
- if folded in seen:
- raise ValueError(f"duplicate Cursor replay tool name: {name!r}")
- seen.add(folded)
- validated.append(name)
- return validated
-
- @staticmethod
- def _write_tool_permissions(workspace: str, tools: List[str], *, is_windows: bool) -> None:
- cursor_dir = os.path.join(workspace, ".cursor")
- os.makedirs(cursor_dir, exist_ok=True)
- commands = [f".\\{tool}.cmd" if is_windows else f"./{tool}" for tool in tools]
- with open(os.path.join(cursor_dir, "cli.json"), "w", encoding="utf-8") as f:
- json.dump(
- {
- "permissions": {
- "allow": [f"Shell({command})" for command in commands],
- "deny": CursorCliBackend._REPLAY_DENY,
- }
- },
- f,
- indent=2,
- )
-
@staticmethod
def _isolated_environment(runtime_dir: str) -> Dict[str, str]:
config_dir = os.path.join(runtime_dir, "config")
@@ -1374,7 +1325,7 @@ def _isolated_environment(runtime_dir: str) -> Dict[str, str]:
"editor": {"vimMode": False},
"permissions": {
"allow": [],
- "deny": CursorCliBackend._REPLAY_DENY,
+ "deny": CursorCliBackend._ASK_DENY,
},
"approvalMode": "allowlist",
"sandbox": {
@@ -1397,15 +1348,7 @@ def _isolated_environment(runtime_dir: str) -> Dict[str, str]:
env["CURSOR_DATA_DIR"] = data_dir
return env
- @staticmethod
- def _tool_invocations(tools: List[str], *, is_windows: bool) -> Tuple[str, str]:
- if is_windows:
- commands = ", ".join(f".\\{tool}.cmd" for tool in tools)
- return commands, f".\\{tools[0]}.cmd \"query\""
- commands = ", ".join(f"./{tool}" for tool in tools)
- return commands, f"./{tools[0]} \"query\""
-
- def _invoke_once(self, prompt: str, workspace: str, *, read_only: bool) -> str:
+ def _invoke_once(self, prompt: str, workspace: str) -> str:
import shutil
from skillopt_sleep.harvest_cursor import CURSOR_REPLAY_SENTINEL
@@ -1416,7 +1359,7 @@ def _invoke_once(self, prompt: str, workspace: str, *, read_only: bool) -> str:
try:
runtime_dir = tempfile.mkdtemp(prefix="skillopt_sleep_cursor_runtime_")
proc = subprocess.run(
- self._command(workspace, read_only=read_only),
+ self._command(workspace),
capture_output=True,
creationflags=_NO_WINDOW,
text=True,
@@ -1430,7 +1373,7 @@ def _invoke_once(self, prompt: str, workspace: str, *, read_only: bool) -> str:
except subprocess.TimeoutExpired:
raise self._error(
f"Cursor Agent timed out after {self.timeout}s",
- retryable=read_only,
+ retryable=True,
)
except Exception as exc:
raise self._error(f"Cursor Agent spawn failed: {exc}")
@@ -1462,7 +1405,7 @@ def _invoke_once(self, prompt: str, workspace: str, *, read_only: bool) -> str:
raise self._error(
"Cursor Agent returned no usable JSON response"
+ (f": {detail[:300]}" if detail else ""),
- retryable=read_only,
+ retryable=True,
)
self.last_call_error = ""
return output
@@ -1473,7 +1416,7 @@ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
try:
for attempt in range(2):
try:
- return self._invoke_once(prompt, workspace, read_only=True)
+ return self._invoke_once(prompt, workspace)
except CursorBackendError as exc:
if not exc.retryable or attempt == 1:
raise
@@ -1493,61 +1436,11 @@ def attempt_with_tools(
memory: str,
tools: List[str],
) -> Tuple[str, List[str]]:
- """Run a tool-aware rollout and report calls from local shim logs."""
- import shutil
- import stat
-
- workspace = tempfile.mkdtemp(prefix="skillopt_sleep_cursortools_")
- calllog = os.path.join(workspace, "_tool_calls.log")
- is_windows = os.name == "nt"
- try:
- try:
- tool_names = self._validated_tool_names(tools)
- except ValueError as exc:
- raise self._error(str(exc))
- self._write_tool_permissions(workspace, tool_names, is_windows=is_windows)
- for tool_name in tool_names:
- if is_windows:
- shim = os.path.join(workspace, f"{tool_name}.cmd")
- with open(shim, "w", encoding="utf-8") as f:
- f.write(
- "@echo off\n"
- f'echo %~n0>>"{calllog}"\n'
- "echo (search results: 3 relevant notes found; use them to answer)\n"
- )
- else:
- shim = os.path.join(workspace, tool_name)
- with open(shim, "w", encoding="utf-8") as f:
- f.write(
- "#!/usr/bin/env bash\n"
- f'echo "{tool_name}" >> "{calllog}"\n'
- 'echo "(search results: 3 relevant notes found; use them to answer)"\n'
- )
- os.chmod(shim, os.stat(shim).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
-
- commands, invocation = self._tool_invocations(
- tool_names,
- is_windows=is_windows,
- )
- prompt = (
- "You are completing a task. Apply the skill and memory rules exactly, "
- "including any rule about searching or looking up information before answering.\n\n"
- f"You have local shell tools: {commands}. When required, actually run one "
- f"before answering (for example, `{invocation}`).\n\n"
- f"# Skill\n{skill or '(none)'}\n\n# Memory\n{memory or '(none)'}\n\n"
- f"# Task\n{task.intent}\n\n{task.context_excerpt}\n\n"
- "Return only the final answer text."
- )
- response = self._invoke_once(prompt, workspace, read_only=False)
- self._tokens += len(prompt) // 4 + len(response) // 4
- called: List[str] = []
- if os.path.exists(calllog):
- with open(calllog, encoding="utf-8") as f:
- logged = {line.strip() for line in f if line.strip()}
- called = [tool for tool in tool_names if tool in logged]
- return response, called
- finally:
- shutil.rmtree(workspace, ignore_errors=True)
+ del task, skill, memory, tools
+ raise self._error(
+ "Cursor tool-aware replay is temporarily disabled pending live "
+ "Cursor permission-boundary validation"
+ )
class DualBackend(Backend):
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/tests/test_plugin_sync.py b/tests/test_plugin_sync.py
index 2f3e75aa..6df09e1d 100644
--- a/tests/test_plugin_sync.py
+++ b/tests/test_plugin_sync.py
@@ -101,8 +101,9 @@ def test_cursor_docs_keep_scheduling_explicit_and_target_relative(self):
text = _read(path)
self.assertIn('"target_skill_path": ".cursor/skills/', text)
self.assertIn("no session-end hook", text.lower())
- self.assertIn("`--force`", text)
- self.assertNotIn("explicit approval", 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)
diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py
index 3397e66b..0ddc6ec2 100644
--- a/tests/test_sleep_engine.py
+++ b/tests/test_sleep_engine.py
@@ -1603,11 +1603,6 @@ class Proc:
CursorCliBackend._parse_json_response('{"type":"message","result":"not terminal"}'),
"",
)
- tool_cmd = backend._command("/tmp/cursor-tools", read_only=False)
- self.assertNotIn("--force", tool_cmd)
- self.assertNotIn("--approve-mcps", tool_cmd)
- self.assertEqual(tool_cmd[tool_cmd.index("--sandbox") + 1], "disabled")
- self.assertNotIn("--mode", tool_cmd)
def test_nonzero_and_error_results_fail_once_with_redacted_diagnostics(self):
from skillopt_sleep.backend import CursorBackendError, CursorCliBackend
@@ -1729,84 +1724,82 @@ class GoodProc:
self.assertEqual(run.call_count, 2)
- def test_cursor_tool_names_permissions_and_windows_invocation_are_scoped(self):
+ 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")
- for unsafe in (
- ["../escape"],
- ["/tmp/escape"],
- ["bad name"],
- ["CON"],
- ["search", "SEARCH"],
- ):
- with self.subTest(tools=unsafe):
- with mock.patch.object(backend, "_invoke_once") as invoke:
- with self.assertRaises(CursorBackendError):
- backend.attempt_with_tools(task, skill="", memory="", tools=unsafe)
- invoke.assert_not_called()
-
- with tempfile.TemporaryDirectory() as workspace:
- backend._write_tool_permissions(workspace, ["search"], is_windows=False)
- with open(os.path.join(workspace, ".cursor", "cli.json"), encoding="utf-8") as f:
- permissions = json.load(f)["permissions"]
- self.assertEqual(permissions["allow"], ["Shell(./search)"])
- self.assertEqual(
- permissions["deny"],
- ["Read(**)", "Write(**)", "Mcp(*:*)"],
- )
- self.assertEqual(
- backend._tool_invocations(["search"], is_windows=True),
- (".\\search.cmd", '.\\search.cmd "query"'),
- )
-
- with mock.patch.object(
- backend,
- "_invoke_once",
- side_effect=CursorBackendError("Cursor Agent timed out", retryable=True),
- ) as invoke:
- with self.assertRaises(CursorBackendError):
+ 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"])
- self.assertEqual(invoke.call_count, 1)
+ run.assert_not_called()
+ self.assertIn("temporarily disabled", backend.last_call_error)
+ self.assertEqual(backend._cache, {})
- def test_attempt_with_tools_uses_actual_cross_platform_shim_log(self):
- import shutil
- import stat
+ 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
- stub_dir = tempfile.mkdtemp(prefix="skillopt_sleep_cursor_stub_")
- try:
- if os.name == "nt":
- stub = os.path.join(stub_dir, "cursor-agent.cmd")
- with open(stub, "w", encoding="utf-8") as f:
- f.write(
- "@echo off\n"
- 'call .\\search.cmd "q" >nul 2>&1\n'
- 'echo {"type":"result","is_error":false,"result":"Paris"}\n'
- )
- else:
- stub = os.path.join(stub_dir, "cursor-agent")
- with open(stub, "w", encoding="utf-8") as f:
- f.write(
- "#!/usr/bin/env bash\n"
- './search "q" >/dev/null 2>&1\n'
- "echo '{\"type\":\"result\",\"is_error\":false,\"result\":\"Paris\"}'\n"
- )
- os.chmod(
- stub,
- os.stat(stub).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH,
- )
-
- backend = CursorCliBackend(cursor_path=stub, timeout=60)
- task = TaskRecord(id="cursor-tools", project="/p", intent="Capital of France?")
- response, called = backend.attempt_with_tools(task, skill="", memory="", tools=["search"])
+ 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()
- self.assertEqual(response, "Paris")
- self.assertEqual(called, ["search"])
- finally:
- shutil.rmtree(stub_dir, ignore_errors=True)
+ 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