diff --git a/README.md b/README.md index 0bbb03d1..a1bb44ce 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ SwarmForge runs locally. Before starting a runnable branch, make sure the target - `git` - `tmux` - Babashka (`bb`) -- At least one configured agent backend, such as `codex`, `claude`, `copilot`, or `grok` +- At least one configured agent backend, such as `codex`, `claude`, `copilot`, `cursor`, or `grok` ## Getting Started @@ -186,7 +186,7 @@ SwarmForge is a lightweight, tmux-based orchestration layer that: - Creates one tmux session per configured role - Serves a **pack cockpit** in the browser and, by default on the pack branches, skips a Terminal window per role (`window-invisible`) - Reads behavior from project-local `swarmforge/roles/.prompt` files plus a layered `swarmforge/constitution.prompt` -- Supports per-role backends such as `claude`, `codex`, `copilot`, or `grok` +- Supports per-role backends such as `claude`, `codex`, `copilot`, `cursor`, or `grok` - Puts the shared `swarmforge/scripts/` directory on each agent's `PATH`, including handoff helpers for active swarm communication - Creates git worktrees under `.worktrees/` for roles assigned to dedicated worktree names - Initializes a git repository in a new working directory when needed @@ -197,7 +197,7 @@ SwarmForge is a lightweight, tmux-based orchestration layer that: - **Config-Driven Topology** — The swarm shape comes from `swarmforge/swarmforge.conf`, not hardcoded shell variables. - **Project-Local Roles** — Each role is defined by `swarmforge/roles/.prompt` in the working tree being orchestrated. - **Layered Constitution** — `swarmforge/constitution.prompt` directs agents to read article files under `swarmforge/constitution/articles/`. -- **Backend Selection Per Role** — A role can launch `claude`, `codex`, `copilot`, or `grok`. +- **Backend Selection Per Role** — A role can launch `claude`, `codex`, `copilot`, `cursor`, or `grok`. - **Pack Cockpit** — A local dashboard for New Task, Attention, the board, Work Queue, master-agent chat, and Teardown. - **Observable Swarm** — Watch agents from the dashboard; open a live pane when you need the raw session. Optional `window` lines still open a Terminal surface per role. - **Self-Hosted & Lightweight** — Runs locally in tmux and a browser, with optional Terminal windows. @@ -341,8 +341,11 @@ Any fields after receive-mode and the propagation token are passed directly to t ```conf window coder copilot wt-coder --yolo window architect claude wt-arch task --dangerously-skip-permissions +window specifier cursor master ``` +The `cursor` backend launches the Cursor CLI (`agent`, or `cursor-agent` if `agent` is not on `PATH`). Authenticate with `agent login` or `CURSOR_API_KEY`. SwarmForge already creates git worktrees; do not pass Cursor `--worktree`. + You can define as many windows as your project needs. Each `role` maps to a corresponding prompt file at `swarmforge/roles/.prompt`, so a config containing `architect`, `coder`, `reviewer`, `research`, and `release` windows would expect: - `swarmforge/roles/architect.prompt` diff --git a/swarmforge/constitution/articles/engineering.prompt b/swarmforge/constitution/articles/engineering.prompt index 264479f3..0fd831a9 100644 --- a/swarmforge/constitution/articles/engineering.prompt +++ b/swarmforge/constitution/articles/engineering.prompt @@ -1,13 +1,15 @@ # Engineering Rules ## Startup Tools -- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language directly from the listed `github.com/unclebob/...` repositories and get each one ready to run. -- Resolve each listed repository at its latest available upstream version before installing or building it. -- Do not rely on stale cached, vendored, or preinstalled copies when a fresh GitHub install/build is possible in the current environment. +- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language from the listed sources and get each one ready to run. +- For Go, Clojure, and Java, resolve each listed `github.com/unclebob/...` repository at its latest available upstream version before installing or building it. +- For Python, install the listed third-party ports with `swarm_tool.sh ensure ` (project-local venv under `.swarmforge/tools/py`). Do not invent `github.com/unclebob/*4py` URLs. +- Do not rely on stale cached, vendored, or preinstalled copies when a fresh install/build from the listed source is possible in the current environment. - Language tool table: - Go: install with `go install`; mutation `github.com/unclebob/mutate4go`, CRAP `github.com/unclebob/crap4go`, DRY `github.com/unclebob/dry4go`. - Clojure: install with Clojure CLI/deps.edn; mutation `github.com/unclebob/clj-mutate`, CRAP `github.com/unclebob/crap4clj`, DRY `github.com/unclebob/dry4clj`. - Java: install with Maven (`mvn`); mutation `github.com/unclebob/mutate4java`, CRAP `github.com/unclebob/crap4java`, DRY `github.com/unclebob/dry4java`. + - Python: install with `swarm_tool.sh ensure`; mutation PyPI `mutate4py` (port of mutate4go), CRAP PyPI `crap4py` (port of crap4go), DRY `dry4python` from `github.com/marandaneto/dry4python` (port of dry4go). ## Language Defaults - For Clojure projects, prefer Babashka where possible. @@ -15,6 +17,8 @@ - Install Speclj with `swarm_tool.sh require speclj`. If missing, `swarm_tool.sh ensure speclj`. - If a Speclj spec file changed, run `swarm_tool.sh require speclj-structure-check` (ensure if missing) and check the spec file before running tests. - For Java projects, avoid using Maven to run tests; build dedicated test runners and run those instead. +- For Python projects, write `pytest` tests, not `unittest`. Keep tests close to the behavior being changed. +- Generate LCOV with `pytest --cov --cov-branch --cov-report=lcov:lcov.info` before `crap4py` or `mutate4py` when those tools require `--lcov`. ## Design And Testability - Work in small, reviewable increments. @@ -48,7 +52,7 @@ - Run the relevant local verification command before handoff whenever the project has one. ## Guardrails -- Do not invent project-local CRAP, DRY, mutation, or coverage proxies. Install and run the constitution tools (`crap4clj` with cloverage, `dry4clj`, `clj-mutate`, speclj, or the language table). Do not treat a homegrown `bb crap` / `bb coverage` / `bb mutation-count` task as those tools. +- Do not invent project-local CRAP, DRY, mutation, or coverage proxies. Install and run the constitution tools (`crap4clj` with cloverage, `dry4clj`, `clj-mutate`, speclj, `crap4py`, `dry4python`, `mutate4py`, or the language table). Do not treat a homegrown `bb crap` / `bb coverage` / `bb mutation-count` task as those tools. - Do not edit mutation testing or Gherkin acceptance mutation manifests by hand; allow approved mutation tools to update those manifests as part of their normal runs. - Do not commit unrelated local changes or generated artifacts unless required for the task. - Before relying on an unfamiliar command, inspect local help or project documentation. diff --git a/swarmforge/scripts/swarm_tool.bb b/swarmforge/scripts/swarm_tool.bb index d2a00fdc..52e8b5a1 100755 --- a/swarmforge/scripts/swarm_tool.bb +++ b/swarmforge/scripts/swarm_tool.bb @@ -31,7 +31,11 @@ "mutate4go" {:source "github.com/unclebob/mutate4go" :bb-task "mutate4go"} "crap4java" {:source "github.com/unclebob/crap4java" :bb-task "crap4java"} "dry4java" {:source "github.com/unclebob/dry4java" :bb-task "dry4java"} - "mutate4java" {:source "github.com/unclebob/mutate4java" :bb-task "mutate4java"}}) + "mutate4java" {:source "github.com/unclebob/mutate4java" :bb-task "mutate4java"} + "mutate4py" {:pip "mutate4py" :cli "mutate4py"} + "crap4py" {:pip "crap4py" :cli "crap4py"} + "dry4python" {:pip "git+https://github.com/marandaneto/dry4python.git" + :cli "dry4python"}}) (def usage-text (str "Usage:\n" @@ -149,7 +153,7 @@ (defn rewrite-bash [tool] (cond - (#{"clj-mutate" "mutate4go" "mutate4java"} tool) (mutate-rewrite-bash) + (#{"clj-mutate" "mutate4go" "mutate4java" "mutate4py" "crap4py"} tool) (mutate-rewrite-bash) (= "gherkin-mutator" tool) (gherkin-rewrite-bash) :else "")) @@ -190,13 +194,44 @@ (when (seq args) (str " " args)) " \"$@\"\n")))) +(defn py-venv-dir [root] + (fs/path root ".swarmforge" "tools" "py")) + +(defn ensure-py-venv! [root] + (let [venv (py-venv-dir root) + python (fs/path venv "bin" "python")] + (when-not (fs/executable? python) + (let [result (sh/sh "python3" "-m" "venv" (str venv))] + (when-not (zero? (:exit result)) + (exit! 1 (str "Failed to create Python venv at " venv "\n" + (:err result) (:out result)))))) + venv)) + +(defn write-pip-wrapper! [root tool spec] + (let [venv (ensure-py-venv! root) + pip (str (fs/path venv "bin" "pip")) + pkg (:pip spec) + cli (or (:cli spec) tool) + install (sh/sh pip "install" "-q" pkg)] + (when-not (zero? (:exit install)) + (exit! 1 (str "Failed to pip install " pkg "\n" + (:err install) (:out install)))) + (write-wrapper! + (wrapper-path root tool) + (str (rewrite-bash tool) + "exec " (sq (str (fs/path venv "bin" cli))) " \"$@\"\n")))) + (defn install-one! [tool] (let [spec (tool-spec tool) root (project-root) name (canonical-tool tool) - target (if-let [bb-task (:bb-task spec)] - (write-bb-wrapper! root name bb-task + target (cond + (:bb-task spec) + (write-bb-wrapper! root name (:bb-task spec) (ensure-source! root (:source spec))) + (:pip spec) + (write-pip-wrapper! root name spec) + :else (write-mvn-wrapper! root name spec))] (println "INSTALLED:" name (str target)))) diff --git a/swarmforge/scripts/swarmforge.bb b/swarmforge/scripts/swarmforge.bb index 79dc6277..257de0e4 100755 --- a/swarmforge/scripts/swarmforge.bb +++ b/swarmforge/scripts/swarmforge.bb @@ -144,7 +144,7 @@ (def receive-modes #{"task" "batch"}) (def propagation-modes #{"forward-only" "back-one" "back-all"}) -(def known-agents #{"claude" "codex" "copilot" "grok"}) +(def known-agents #{"claude" "codex" "copilot" "cursor" "grok"}) (defn receive-fields [trailing] (let [[receive-mode after-receive] @@ -375,9 +375,18 @@ (when-not (command-exists? command) (fail! (str red "Error:" reset " '" command "' is required but not installed.")))) +(defn backend-binaries [agent] + (case agent + "cursor" ["agent" "cursor-agent"] + [agent])) + (defn check-backend-dependencies! [ctx] - (doseq [agent (map :agent (:roles ctx))] - (check-dependency! agent))) + (doseq [agent (distinct (map :agent (:roles ctx)))] + (let [bins (backend-binaries agent)] + (when-not (some command-exists? bins) + (fail! (str red "Error:" reset " '" + (str/join "' or '" bins) + "' is required but not installed.")))))) (defn create-role-session! [ctx session title] (sh "tmux" "-S" (:tmux-socket ctx) "new-session" "-d" "-s" session "-n" agent-window) @@ -470,8 +479,12 @@ "codex" (if (extra-has? row "--yolo") "" "--yolo ") "copilot" (if (extra-has? row "--yolo") "" "--yolo ") "claude" (if (extra-has? row "bypassPermissions") "" "--permission-mode bypassPermissions ") + "cursor" (if (or (extra-has? row "--yolo") (extra-has? row "--force")) "" "--yolo ") "")) +(defn cursor-trust-flag [row] + (if (extra-has? row "--trust") "" "--trust ")) + (defn grok-permission-prefix [row] "--permission-mode bypassPermissions ") @@ -523,7 +536,12 @@ "grok" (str "grok --cwd " (sq (str role-worktree)) " " (grok-permission-prefix row) (extra-args-prefix row) "--minimal --rules " prompt - (when initial-prompt? (str " --verbatim " prompt))))) + (when initial-prompt? (str " --verbatim " prompt))) + "cursor" (str "$(command -v agent || command -v cursor-agent) --workspace " + (sq (str role-worktree)) " " + (cursor-trust-flag row) (yolo-flag agent row) + (extra-args-prefix row) + (when initial-prompt? prompt)))) (= index 0) (str "; exit_code=$?; SWARMFORGE_TERMINAL_BACKEND=" (sq (:terminal-backend ctx)) " nohup " (sq (str (fs/path (:script-dir ctx) "swarm-cleanup.sh"))) diff --git a/swarmforge/swarmforge.conf b/swarmforge/swarmforge.conf index 434c9d86..955c49b9 100644 --- a/swarmforge/swarmforge.conf +++ b/swarmforge/swarmforge.conf @@ -1,3 +1,4 @@ # Host lieutenant. Default is grok with no extra args. # Lieutenant grok --yolo # Lieutenant claude --yolo +# Lieutenant cursor diff --git a/test/swarmforge/coverage_in_process_test.clj b/test/swarmforge/coverage_in_process_test.clj index 52711ece..4caa0864 100644 --- a/test/swarmforge/coverage_in_process_test.clj +++ b/test/swarmforge/coverage_in_process_test.clj @@ -264,6 +264,11 @@ (is (= "" (swarmforge/yolo-flag "codex" {:extra-args "--yolo"}))) (is (= "--permission-mode bypassPermissions " (swarmforge/yolo-flag "claude" {:extra-args ""}))) + (is (= "--yolo " (swarmforge/yolo-flag "cursor" {:extra-args ""}))) + (is (= "" (swarmforge/yolo-flag "cursor" {:extra-args "--yolo"}))) + (is (= "" (swarmforge/yolo-flag "cursor" {:extra-args "--force"}))) + (is (= ["agent" "cursor-agent"] (swarmforge/backend-binaries "cursor"))) + (is (= ["claude"] (swarmforge/backend-binaries "claude"))) (is (= "" (swarmforge/yolo-flag "unknown" {:extra-args ""}))) (is (swarmforge/skip-config-line? "# hi")) (is (swarmforge/skip-config-line? "")) diff --git a/test/swarmforge/script_test.clj b/test/swarmforge/script_test.clj index 0c004866..b3324fa1 100644 --- a/test/swarmforge/script_test.clj +++ b/test/swarmforge/script_test.clj @@ -448,11 +448,12 @@ ;; Given each pack backend ;; When SwarmForge builds the launch command ;; Then Codex and Copilot use --no-alt-screen, Claude disables the - ;; alternate screen, and Grok keeps --minimal + ;; alternate screen, Grok keeps --minimal, and Cursor pins --workspace (doseq [[agent needle] [["codex" "--no-alt-screen"] ["copilot" "--no-alt-screen"] ["claude" "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"] - ["grok" "--minimal"]]] + ["grok" "--minimal"] + ["cursor" "--workspace "]]] (let [root (tmp-dir)] (try (let [command (:out (run {:dir root} @@ -487,7 +488,8 @@ (doseq [[agent needle] [["codex" "--yolo"] ["copilot" "--yolo"] ["claude" "--permission-mode bypassPermissions"] - ["grok" "--permission-mode bypassPermissions"]]] + ["grok" "--permission-mode bypassPermissions"] + ["cursor" "--yolo"]]] (let [root (tmp-dir)] (try (let [command (:out (run {:dir root} @@ -499,6 +501,92 @@ (finally (fs/delete-tree root)))))) +(deftest cursor-launch-command-uses-workspace-trust-and-prompt + ;; Given a cursor pack role + ;; When SwarmForge builds the launch command + ;; Then it resolves agent or cursor-agent, pins --workspace and --trust, + ;; and cats the generated prompt as the first message + (let [root (tmp-dir)] + (try + (let [command (:out (run {:dir root} + (script "swarmforge.bb") + "--test-launch-command" + (str root) + "cursor"))] + (is (str/includes? command "$(command -v agent || command -v cursor-agent)")) + (is (str/includes? command "--workspace ")) + (is (str/includes? command "--trust ")) + (is (str/includes? command "--yolo ")) + (is (not (str/includes? command " --worktree "))) + (is (not (str/includes? command " -p "))) + (is (not (str/includes? command "--print"))) + (is (str/includes? command "\"$(cat ")) + (is (str/includes? command ".swarmforge/prompts/coder.md")) + (is (fs/exists? (fs/path root ".swarmforge/prompts/coder.md")))) + (finally + (fs/delete-tree root))))) + +(deftest cursor-launch-command-passes-extra-cli-args + (let [root (tmp-dir)] + (try + (let [command (:out (run {:dir root} + (script "swarmforge.bb") + "--test-launch-command" + (str root) + "cursor" + "--model composer-2.5"))] + (is (str/includes? command "--model composer-2.5")) + (is (str/includes? command "--yolo "))) + (finally + (fs/delete-tree root))))) + +(deftest cursor-launch-skips-duplicate-yolo-and-trust + (let [root (tmp-dir)] + (try + (let [command (:out (run {:dir root} + (script "swarmforge.bb") + "--test-launch-command" + (str root) + "cursor" + "--force --trust"))] + (is (str/includes? command "--force")) + (is (str/includes? command "--trust")) + (is (not (str/includes? command "--yolo ")))) + (finally + (fs/delete-tree root))))) + +(deftest cursor-lieutenant-launch-waits-for-chat + ;; Given a host lieutenant on cursor + ;; When SwarmForge builds the lieutenant launch command + ;; Then it does not pass an initial prompt + (let [root (tmp-dir)] + (try + (write-file (fs/path root "swarmforge/swarmforge.conf") "Lieutenant cursor\n") + (let [command (:out (run {:dir root} + (script "swarmforge.bb") + "--test-lieutenant-launch-command" + (str root)))] + (is (str/includes? command "$(command -v agent || command -v cursor-agent)")) + (is (str/includes? command "--workspace ")) + (is (fs/exists? (fs/path root ".swarmforge/prompts/lieutenant.md"))) + (is (not (str/includes? command ".swarmforge/prompts/lieutenant.md")) + "lieutenant must not send an initial user prompt")) + (finally + (fs/delete-tree root))))) + +(deftest swarmforge-rejects-unknown-agent + (let [root (tmp-dir)] + (try + (write-file (fs/path root "swarmforge/constitution.prompt") "Read articles.\n") + (write-file (fs/path root "swarmforge/swarmforge.conf") + "window coder foobar master\n") + (write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n") + (let [result (run {:dir root :ok? false} (script "swarmforge.bb") "--test-parse" (str root))] + (is (= 1 (:exit result))) + (is (str/includes? (:err result) "Unsupported agent 'foobar'"))) + (finally + (fs/delete-tree root))))) + (deftest launch-command-puts-project-tool-bin-on-path ;; Given a launched role ;; When the start command is built @@ -608,7 +696,26 @@ (is (str/includes? (str (:err help) (:out help)) "dry4clj")) (is (str/includes? (str (:err help) (:out help)) "cloverage")) (is (str/includes? (str (:err help) (:out help)) "speclj")) - (is (str/includes? (str (:err help) (:out help)) "speclj-structure-check"))) + (is (str/includes? (str (:err help) (:out help)) "speclj-structure-check")) + (is (str/includes? (str (:err help) (:out help)) "mutate4py")) + (is (str/includes? (str (:err help) (:out help)) "crap4py")) + (is (str/includes? (str (:err help) (:out help)) "dry4python"))) + (finally + (fs/delete-tree root))))) + +(deftest swarm-tool-ensure-crap4py-installs-venv-wrapper + ;; Given a pack project + ;; When swarm_tool.sh ensure crap4py + ;; Then a project-local venv wrapper is installed and require succeeds + (let [root (tmp-dir)] + (try + (write-file (fs/path root ".swarmforge/roles.tsv") + (format "specifier\tmaster\t%s\tsession\tSpecifier\tcodex\ttask\n" root)) + (run {:dir root} (script "swarm_tool.sh") "ensure" "crap4py") + (let [wrapper (slurp (str (fs/path root ".swarmforge/bin/crap4py")))] + (is (str/includes? wrapper ".swarmforge/tools/py/bin/crap4py")) + (is (fs/executable? (fs/path root ".swarmforge/tools/py/bin/crap4py"))) + (is (zero? (:exit (run {:dir root} (script "swarm_tool.sh") "require" "crap4py"))))) (finally (fs/delete-tree root)))))