diff --git a/plugins/devin/README.md b/plugins/devin/README.md index eafce9f..3a6bbd7 100644 --- a/plugins/devin/README.md +++ b/plugins/devin/README.md @@ -58,6 +58,9 @@ Requires Python ≥ 3.10. No third-party packages — the server is pure stdlib. | `sleep_run` | full cycle; stages a proposal for review | | `sleep_adopt` | apply the staged proposal; syncs skill to the workspace | | `sleep_harvest` | debug: list the recurring tasks mined | +| `sleep_schedule` | install a nightly cron entry (`--hour` / `--minute`) | +| `sleep_unschedule` | remove the nightly cron entry | Default backend is `mock` (no API spend); `--backend claude|codex` uses your own -budget. Same engine as the other plugins — all call `python -m skillopt_sleep`. +budget. Same engine and `sleep_*` interface as the other plugins — all call +`python -m skillopt_sleep`. diff --git a/plugins/devin/devin-rules.snippet.md b/plugins/devin/devin-rules.snippet.md index bed86d7..7ca59a8 100644 --- a/plugins/devin/devin-rules.snippet.md +++ b/plugins/devin/devin-rules.snippet.md @@ -8,6 +8,7 @@ server. Use these tools to improve your long-term skills over time: - **`sleep_run`** — run a full cycle; stages a proposal for review - **`sleep_adopt`** — apply the staged proposal to `.devin/skills/skillopt-sleep-learned/SKILL.md` - **`sleep_harvest`** — debug: list the recurring tasks mined from recent sessions +- **`sleep_schedule`** / **`sleep_unschedule`** — install/remove a nightly cron run When a user asks about the sleep cycle, skill evolution, or improving your long-term memory, prefer calling these tools over explaining the concept. diff --git a/plugins/devin/mcp_server.py b/plugins/devin/mcp_server.py index e5071e3..fe57168 100644 --- a/plugins/devin/mcp_server.py +++ b/plugins/devin/mcp_server.py @@ -1,30 +1,22 @@ #!/usr/bin/env python3 """SkillOpt-Sleep — Devin MCP server (stdio, stdlib-only). -Exposes the sleep engine as MCP tools so Devin can drive it. -Speaks JSON-RPC 2.0 over stdio with just the handful of MCP methods Devin -needs. No third-party deps beyond the SkillOpt repo itself. +Exposes the sleep engine as MCP tools so Devin (Cognition) can drive it. No +third-party deps: speaks JSON-RPC 2.0 over stdio with just the handful of MCP +methods clients need. Same `sleep_*` interface and engine flags as +`plugins/copilot`, plus a Devin-specific harvest step. -Before each tool call this server runs ``harvest_devin.py`` to convert -locally available Devin data (ATIF-v1.7 transcripts, agentmemory memories, -and .devin skill files) into the Claude Code-compatible JSONL transcripts -that the sleep engine consumes. +Before each data-reading action this server runs `harvest_devin.py` to convert +locally available Devin data (ATIF-v1.7 transcripts, agentmemory memories, and +.devin skill files) into the Claude Code-compatible JSONL the engine consumes, +writing it under SKILLOPT_DEVIN_CLAUDE_HOME and pointing the engine there with +`--claude-home`. After `sleep_adopt` the evolved skill is synced back into the +workspace's `.devin/skills/`. -After ``sleep_adopt`` the evolved SKILL.md is also synced back into the active -Devin workspace's ``.devin/skills/`` directory so Devin picks it up immediately. - -Tools exposed (identical interface to the Copilot plugin): - sleep_status show how many nights have run + latest staged proposal - sleep_dry_run harvest+mine+replay, report only (no staging) - sleep_run full cycle; stages a reviewed proposal - sleep_adopt apply the latest staged proposal - sleep_harvest debug: list mined recurring tasks - -Configure Devin to launch:: - - python plugins/devin/mcp_server.py - -with ``SKILLOPT_SLEEP_REPO`` set to this repo's root. +Tools: sleep_status, sleep_dry_run, sleep_run, sleep_adopt, sleep_harvest, +sleep_schedule, sleep_unschedule. Each shells out to +`python -m skillopt_sleep ...`. Configure Devin to launch: + python plugins/devin/mcp_server.py """ from __future__ import annotations @@ -34,8 +26,8 @@ import shutil import subprocess import sys -# ── constants ───────────────────────────────────────────────────────────────── - +# expanduser wraps the whole value so a "~/..." env var is expanded too (not +# just a default) — otherwise a literal ~ dir gets created. REPO_ROOT = os.path.expanduser( os.environ.get("SKILLOPT_SLEEP_REPO") or os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) @@ -48,57 +40,68 @@ MANAGED_SKILL_NAME = os.environ.get("SKILLOPT_MANAGED_SKILL", "skillopt-sleep-le PROTOCOL_VERSION = "2024-11-05" TOOLS = [ - { - "name": "sleep_status", - "action": "status", - "description": "Show how many SkillOpt-Sleep nights have run and the latest staged proposal.", - }, - { - "name": "sleep_dry_run", - "action": "dry-run", - "description": "Preview a sleep cycle (harvest+mine+replay) without staging anything.", - }, - { - "name": "sleep_run", - "action": "run", - "description": "Run a full sleep cycle; stages a reviewed proposal. Nothing live changes until adopt.", - }, - { - "name": "sleep_adopt", - "action": "adopt", - "description": ( - "Apply the latest staged proposal to the managed SKILL.md. " - "Also syncs the evolved skill into the Devin workspace so Devin picks it up immediately." - ), - }, - { - "name": "sleep_harvest", - "action": "harvest", - "description": "Debug: list the recurring tasks mined from recent Devin sessions.", - }, + {"name": "sleep_status", "action": "status", + "description": "Show how many SkillOpt-Sleep nights have run and the latest staged proposal."}, + {"name": "sleep_dry_run", "action": "dry-run", + "description": "Preview a sleep cycle (harvest+mine+replay) without staging anything."}, + {"name": "sleep_run", "action": "run", + "description": "Run a full sleep cycle; stages a reviewed proposal. Nothing live changes until adopt."}, + {"name": "sleep_adopt", "action": "adopt", + "description": "Apply the latest staged proposal to the managed SKILL.md and sync it into .devin/skills/."}, + {"name": "sleep_harvest", "action": "harvest", + "description": "Debug: list the recurring tasks mined from recent Devin sessions."}, + {"name": "sleep_schedule", "action": "schedule", + "description": "Install a nightly cron entry to run the sleep cycle automatically."}, + {"name": "sleep_unschedule", "action": "unschedule", + "description": "Remove the nightly cron entry for a project."}, ] _BY_NAME = {t["name"]: t for t in TOOLS} _TOOL_SCHEMA = { "type": "object", "properties": { - "project": { - "type": "string", - "description": "Project dir to evolve (default: cwd).", - }, - "backend": { - "type": "string", - "enum": ["mock", "claude", "codex"], - "description": "mock = no API spend (default); claude/codex = real.", - }, - "scope": {"type": "string", "enum": ["invoked", "all"]}, + "project": {"type": "string", + "description": "Project dir to evolve (default: cwd)."}, + "backend": {"type": "string", "enum": ["mock", "claude", "codex", "copilot"], + "description": "mock = no API spend (default); claude/codex/copilot = real."}, + "scope": {"type": "string", "enum": ["invoked", "all"], + "description": "Harvest scope (default: invoked project only)."}, + "source": {"type": "string", "enum": ["claude", "codex", "auto"], + "description": "Transcript source (default: claude)."}, + "model": {"type": "string", + "description": "Backend-specific model override."}, + "tasks_file": {"type": "string", + "description": "Path to reviewed TaskRecord JSON (skips harvest)."}, + "target_skill_path": {"type": "string", + "description": "Explicit SKILL.md path to evolve/stage/adopt."}, + "progress": {"type": "boolean", + "description": "Print phase progress to stderr."}, + "max_sessions": {"type": "integer", + "description": "Cap harvested sessions per run."}, + "max_tasks": {"type": "integer", + "description": "Cap mined tasks per run."}, + "lookback_hours": {"type": "integer", + "description": "Harvest window in hours (default: 72)."}, + "auto_adopt": {"type": "boolean", + "description": "Auto-adopt if gate passes (default: false)."}, + "json": {"type": "boolean", + "description": "Return machine-readable JSON output."}, + "edit_budget": {"type": "integer", + "description": "Max bounded edits per night (default: 4)."}, + "hour": {"type": "integer", + "description": "Hour for schedule (0-23, default: 3)."}, + "minute": {"type": "integer", + "description": "Minute for schedule (0-59, default: 17)."}, }, "additionalProperties": False, } -# ── harvest step ────────────────────────────────────────────────────────────── +# actions that read harvested Devin data (schedule/unschedule/adopt don't) +_HARVEST_ACTIONS = {"status", "dry-run", "run", "harvest"} + def _run_harvest() -> str: + """Convert local Devin data into the JSONL the engine reads, under CLAUDE_HOME.""" harvester = os.path.join(PLUGIN_DIR, "harvest_devin.py") env = dict(os.environ) env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "") @@ -113,61 +116,72 @@ def _run_harvest() -> str: except Exception as exc: return f"[harvest_devin] warning: {exc}" -# ── post-adopt: sync evolved skill into workspace (.devin) ──────────────────── def _sync_skill(project: str) -> str: + """After adopt, copy the evolved skill into the workspace's .devin/skills/.""" src = os.path.join(CLAUDE_HOME, "skills", MANAGED_SKILL_NAME, "SKILL.md") - if not os.path.isfile(src): + if not (os.path.isfile(src) and project and os.path.isdir(project)): return "" - if not project or not os.path.isdir(project): - return "" - synced = [] dot_root = os.path.join(project, ".devin") - if os.path.isdir(dot_root): - dst_dir = os.path.join(dot_root, "skills", MANAGED_SKILL_NAME) - os.makedirs(dst_dir, exist_ok=True) - dst = os.path.join(dst_dir, "SKILL.md") - shutil.copy2(src, dst) - synced.append(dst) - return ("\n" + "\n".join(f"[sleep] synced evolved skill → {p}" for p in synced) - if synced else "") + if not os.path.isdir(dot_root): + return "" + dst_dir = os.path.join(dot_root, "skills", MANAGED_SKILL_NAME) + os.makedirs(dst_dir, exist_ok=True) + dst = os.path.join(dst_dir, "SKILL.md") + shutil.copy2(src, dst) + return f"\n[sleep] synced evolved skill → {dst}" -# ── engine call ─────────────────────────────────────────────────────────────── def _run_engine(action: str, args: dict) -> str: - harvest_out = _run_harvest() + harvest_out = _run_harvest() if action in _HARVEST_ACTIONS else "" - project = args.get("project") or os.getcwd() - backend = args.get("backend") or "mock" - scope = args.get("scope") or "invoked" + py = sys.executable or "python3" + cmd = [py, "-m", "skillopt_sleep", action, "--claude-home", CLAUDE_HOME] + # Devin transcripts are converted to the Claude format, so default source=claude + if not args.get("source"): + cmd += ["--source", "claude"] + # String-valued flags + for flag, key in [ + ("--project", "project"), ("--backend", "backend"), + ("--scope", "scope"), ("--source", "source"), + ("--model", "model"), ("--tasks-file", "tasks_file"), + ("--target-skill-path", "target_skill_path"), + ]: + val = args.get(key) + if val: + cmd += [flag, str(val)] + # Integer-valued flags + for flag, key in [ + ("--max-sessions", "max_sessions"), ("--max-tasks", "max_tasks"), + ("--lookback-hours", "lookback_hours"), ("--edit-budget", "edit_budget"), + ("--hour", "hour"), ("--minute", "minute"), + ]: + val = args.get(key) + if val is not None: + cmd += [flag, str(int(val))] + # Boolean flags + for flag, key in [ + ("--progress", "progress"), ("--auto-adopt", "auto_adopt"), ("--json", "json"), + ]: + if args.get(key): + cmd.append(flag) - cmd = [ - sys.executable, "-m", "skillopt_sleep", action, - "--claude-home", CLAUDE_HOME, - "--project", project, - "--scope", scope, - "--backend", backend, - "--source", "claude", - ] env = dict(os.environ) env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "") try: - proc = subprocess.run( - cmd, capture_output=True, text=True, timeout=3600, env=env, - ) - except Exception as exc: - return f"[harvest]\n{harvest_out}\n[error] failed to run engine: {exc}" - + proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, + text=True, timeout=3600, env=env) + except Exception as e: + return f"[harvest]\n{harvest_out}\n[error] failed to run engine: {e}" out = (proc.stdout or "").strip() err = (proc.stderr or "").strip() - result = f"[harvest]\n{harvest_out}\n\n[engine]\n{out}" + result = (f"[harvest]\n{harvest_out}\n\n" if harvest_out else "") + f"[engine]\n{out}" if err: result += f"\n[stderr]\n{err}" if action == "adopt": - result += _sync_skill(project) + result += _sync_skill(args.get("project") or os.getcwd()) return result -# ── JSON-RPC / MCP plumbing ─────────────────────────────────────────────────── def _result(id_, result): return {"jsonrpc": "2.0", "id": id_, "result": result} @@ -184,14 +198,13 @@ def handle(req: dict): return _result(id_, { "protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}}, - "serverInfo": {"name": "skillopt-sleep-devin", "version": "0.1.0"}, + "serverInfo": {"name": "skillopt-sleep", "version": "0.1.0"}, }) if method in ("notifications/initialized", "initialized"): return None if method == "tools/list": return _result(id_, {"tools": [ - {"name": t["name"], "description": t["description"], - "inputSchema": _TOOL_SCHEMA} + {"name": t["name"], "description": t["description"], "inputSchema": _TOOL_SCHEMA} for t in TOOLS ]}) if method == "tools/call": diff --git a/tests/test_devin_plugin.py b/tests/test_devin_plugin.py index 3fa3da9..fb276b9 100644 --- a/tests/test_devin_plugin.py +++ b/tests/test_devin_plugin.py @@ -33,20 +33,31 @@ class TestDevinMcpSchema(unittest.TestCase): def test_tools_are_the_sleep_interface(self): names = {t["name"] for t in mcp_server.TOOLS} self.assertEqual(names, {"sleep_status", "sleep_dry_run", "sleep_run", - "sleep_adopt", "sleep_harvest"}) + "sleep_adopt", "sleep_harvest", + "sleep_schedule", "sleep_unschedule"}) def test_actions_map_to_engine_subcommands(self): expected = {"sleep_status": "status", "sleep_dry_run": "dry-run", "sleep_run": "run", "sleep_adopt": "adopt", - "sleep_harvest": "harvest"} + "sleep_harvest": "harvest", "sleep_schedule": "schedule", + "sleep_unschedule": "unschedule"} for t in mcp_server.TOOLS: self.assertEqual(t["action"], expected[t["name"]]) def test_backends_in_enum(self): backends = mcp_server._TOOL_SCHEMA["properties"]["backend"]["enum"] - for b in ["mock", "claude", "codex"]: + for b in ["mock", "claude", "codex", "copilot"]: self.assertIn(b, backends) + def test_schema_has_key_engine_params(self): + # parity with plugins/copilot's schema (tests/test_plugin_sync.py) + props = set(mcp_server._TOOL_SCHEMA["properties"].keys()) + for param in {"project", "backend", "scope", "source", "model", + "tasks_file", "target_skill_path", "max_sessions", + "max_tasks", "lookback_hours", "auto_adopt", "json", + "edit_budget", "hour", "minute"}: + self.assertIn(param, props) + class TestClaudeHomeExpansion(unittest.TestCase): """Regression: ~ must be expanded even when CLAUDE_HOME comes from the env