diff --git a/.gitignore b/.gitignore index 02be7d65..b4477032 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .claude/ .swarmforge/ .worktrees/ +.tmp/ +projects/ diff --git a/README.md b/README.md index 98115028..ddfe8d95 100644 --- a/README.md +++ b/README.md @@ -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/.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 @@ -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/.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. diff --git a/swarmforge/scripts/swarmforge.bb b/swarmforge/scripts/swarmforge.bb index ff4e9d3d..90327b49 100755 --- a/swarmforge/scripts/swarmforge.bb +++ b/swarmforge/scripts/swarmforge.bb @@ -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"))) @@ -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)] @@ -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"))) @@ -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)) + " >" + (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") "." @@ -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 diff --git a/test/swarmforge/script_test.clj b/test/swarmforge/script_test.clj index c7ed1e34..db2ad77b 100644 --- a/test/swarmforge/script_test.clj +++ b/test/swarmforge/script_test.clj @@ -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