Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<role>.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
Expand All @@ -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/<role>.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.
Expand Down Expand Up @@ -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/<role>.prompt`, so a config containing `architect`, `coder`, `reviewer`, `research`, and `release` windows would expect:

- `swarmforge/roles/architect.prompt`
Expand Down
12 changes: 8 additions & 4 deletions swarmforge/constitution/articles/engineering.prompt
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
# 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 <tool>` (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.
- For Clojure or Babashka projects, write Speclj specs, not `clojure.test`.
- 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.
Expand Down Expand Up @@ -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.
43 changes: 39 additions & 4 deletions swarmforge/scripts/swarm_tool.bb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 ""))

Expand Down Expand Up @@ -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))))

Expand Down
26 changes: 22 additions & 4 deletions swarmforge/scripts/swarmforge.bb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ")

Expand Down Expand Up @@ -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")))
Expand Down
1 change: 1 addition & 0 deletions swarmforge/swarmforge.conf
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Host lieutenant. Default is grok with no extra args.
# Lieutenant grok --yolo
# Lieutenant claude --yolo
# Lieutenant cursor
5 changes: 5 additions & 0 deletions test/swarmforge/coverage_in_process_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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? ""))
Expand Down
115 changes: 111 additions & 4 deletions test/swarmforge/script_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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
Expand Down Expand Up @@ -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)))))

Expand Down