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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
.claude/
.swarmforge/
.worktrees/
.tmp/
projects/
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ SwarmForge is a lightweight, tmux-based orchestration layer that:
- Launches a **config-driven swarm** from a project-local `swarmforge/swarmforge.conf`
- Creates one tmux session per configured role and opens a terminal surface for each role when the selected backend supports it
- 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`, `grok`, or `opencode`
- 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 @@ -102,9 +102,10 @@ SwarmForge is a lightweight, tmux-based orchestration layer that:
## Core Features

- **Config-Driven Topology** — The swarm shape comes from `swarmforge/swarmforge.conf`, not hardcoded shell variables.
- **Alternate Configurations** — Set `SWARMFORGE_CONFIG` to a project-local alternate configuration filename for a bounded smoke workflow. A smoke-only project may additionally set `SWARMFORGE_INSTRUCTION_PROFILE=role-only` to replace the normal recursive constitution bootstrap with the explicitly scoped role prompt; this is not appropriate for product work.
- **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`, `grok`, or `opencode`. For OpenCode, set the provider and model as role arguments, for example `--model opencode-go/qwen3.7-plus` or `--model opencode/deepseek-v4-pro`.
- **Observable Swarm** — Open one Terminal window per role and watch the sessions in real time.
- **Self-Hosted & Lightweight** — Runs locally in tmux and Terminal with minimal machinery.

Expand Down
36 changes: 27 additions & 9 deletions swarmforge/scripts/swarmforge.bb
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@
(fail! (str red "Error:" reset " Duplicate worktree '" worktree "' in " (:config-file ctx))))
(when (or (str/includes? worktree "/") (#{"." ".."} worktree))
(fail! (str red "Error:" reset " Invalid worktree '" worktree "' for role '" role "'")))
(when-not (#{"claude" "codex" "copilot" "grok"} agent)
(when-not (#{"claude" "codex" "copilot" "grok" "opencode"} agent)
(fail! (str red "Error:" reset " Unsupported agent '" agent "' for role '" role "'")))
(when-not (#{"task" "batch"} receive-mode)
(fail! (str red "Error:" reset " Invalid receive mode '" receive-mode "' for role '" role "' on line " line-no ": expected task or batch")))
Expand Down Expand Up @@ -298,10 +298,21 @@
(sh "tmux" "-S" (:tmux-socket ctx) "rename-window" "-t" (str session ":" agent-window) title)
(sh "tmux" "-S" (:tmux-socket ctx) "set-window-option" "-t" (str session ":" title) "allow-rename" "off"))

(defn instruction-profile []
;; A project may opt into this only for a bounded, non-product smoke run.
;; Normal swarms retain the constitution-first recursive instruction.
(case (System/getenv "SWARMFORGE_INSTRUCTION_PROFILE")
"role-only" :role-only
:standard))

(defn write-agent-instruction-file! [role prompt-file]
(spit (str prompt-file)
(str "Read swarmforge/constitution.prompt, then read every file it refers to recursively, and obey all of those instructions.\n"
"Read swarmforge/roles/" role ".prompt, then read every file it refers to recursively, and follow all of those instructions.\n")))
(case (instruction-profile)
:role-only
(str "This is a bounded smoke workflow. Read swarmforge/roles/" role
".prompt and follow it exactly. Do not read project constitution files unless that role prompt explicitly requires them.\n")
(str "Read swarmforge/constitution.prompt, then read every file it refers to recursively, and obey all of those instructions.\n"
"Read swarmforge/roles/" role ".prompt, then read every file it refers to recursively, and follow all of those instructions.\n"))))

(defn extra-args-prefix [row]
(let [args (:extra-args row)]
Expand Down Expand Up @@ -339,7 +350,8 @@
"claude" (str "claude --append-system-prompt-file " (sq (str prompt-file)) " --permission-mode acceptEdits -n " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")
"codex" (str "codex -C " (sq (str role-worktree)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")
"copilot" (str "copilot -C " (sq (str role-worktree)) " --name " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "-i \"$(cat " (sq (str prompt-file)) ")\"")
"grok" (str "grok --cwd " (sq (str role-worktree)) " " (grok-permission-prefix row) (extra-args-prefix row) "--rules \"$(cat " (sq (str prompt-file)) ")\" --verbatim \"$(cat " (sq (str prompt-file)) ")\"")))
"grok" (str "grok --cwd " (sq (str role-worktree)) " " (grok-permission-prefix row) (extra-args-prefix row) "--rules \"$(cat " (sq (str prompt-file)) ")\" --verbatim \"$(cat " (sq (str prompt-file)) ")\"")
"opencode" (str "opencode " (sq (str role-worktree)) " " (extra-args-prefix row) "--prompt \"$(cat " (sq (str prompt-file)) ")\"")))
(= 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 Expand Up @@ -389,10 +401,16 @@
(fs/delete-if-exists (fs/path (:daemon-dir ctx) "stop"))
(let [command (into (vec (sleep-inhibitor-prefix))
[(str (fs/path (:script-dir ctx) "handoffd.bb"))
(str (:working-dir ctx))])]
(process/process command
{:out (str (:handoff-daemon-log ctx))
:err :out})
(str (:working-dir ctx))])
detached-command (str "nohup "
(str/join " " (map sq command))
" </dev/null >>"
(sq (str (:handoff-daemon-log ctx)))
" 2>&1 &")]
;; A babashka-managed child can be terminated with its launcher when the
;; terminal bridge detaches. The handoff daemon must instead outlive that
;; bridge and follow its explicit stop-file protocol.
(sh "zsh" "-c" detached-command)
(println (str green "Started handoff daemon"
(when (> (count command) 2) " with OS sleep prevention")
"."
Expand Down Expand Up @@ -467,7 +485,7 @@
:script-dir script-dir
:swarm-forge-dir swarm-forge-dir
:worktrees-dir (fs/path working-dir ".worktrees")
:config-file (fs/path swarm-forge-dir "swarmforge.conf")
:config-file (fs/path swarm-forge-dir (or (System/getenv "SWARMFORGE_CONFIG") "swarmforge.conf"))
:roles-dir (fs/path swarm-forge-dir "roles")
:constitution-file (fs/path swarm-forge-dir "constitution.prompt")
:state-dir state-dir
Expand Down
33 changes: 33 additions & 0 deletions test/swarmforge/script_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,39 @@
(finally
(fs/delete-tree root)))))

(deftest opencode-launch-command-passes-model-and-initial-prompt
(let [root (tmp-dir)]
(try
(let [result (run {:dir root}
(script "swarmforge.bb")
"--test-launch-command"
(str root)
"opencode"
"--model opencode-go/qwen3.7-plus --auto")
command (:out result)]
(is (str/includes? command "opencode '"))
(is (str/includes? command "--model opencode-go/qwen3.7-plus --auto --prompt"))
(is (str/includes? command ".swarmforge/prompts/coder.md")))
(finally
(fs/delete-tree root)))))

(deftest swarmforge-role-only-instruction-profile-is-explicit-and-bounded
(let [root (tmp-dir)]
(try
(let [result (run {:dir root
:env {"SWARMFORGE_INSTRUCTION_PROFILE" "role-only"}}
(script "swarmforge.bb")
"--test-launch-command"
(str root)
"opencode")
prompt (slurp (str (fs/path root ".swarmforge/prompts/coder.md")))]
(is (str/includes? (:out result) "opencode '"))
(is (str/includes? prompt "bounded smoke workflow"))
(is (str/includes? prompt "Read swarmforge/roles/coder.prompt"))
(is (not (str/includes? prompt "Read swarmforge/constitution.prompt"))))
(finally
(fs/delete-tree root)))))

(deftest grok-launch-command-uses-bypass-permissions-with-always-approve
(let [root (tmp-dir)]
(try
Expand Down