Integrating swarm-forge into project

This commit is contained in:
Chris Troutner
2026-08-25 14:50:21 -07:00
parent f6e987b135
commit a8230982a4
53 changed files with 0 additions and 165 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bb
(ns done-with-current
(:require [babashka.fs :as fs]
[babashka.process :as process]
[clojure.java.shell :as sh]
[clojure.string :as str]))
(def script-dir (fs/parent *file*))
(defn exit! [status message]
(binding [*out* *err*]
(println message))
(System/exit status))
(defn command [& args]
(apply sh/sh args))
(defn git-root []
(let [result (command "git" "rev-parse" "--show-toplevel")]
(when (zero? (:exit result))
(str/trim (:out result)))))
(defn git-common-dir []
(let [result (command "git" "rev-parse" "--git-common-dir")]
(when (zero? (:exit result))
(let [path (str/trim (:out result))]
(if (fs/absolute? path)
path
(str (fs/absolutize path)))))))
(defn project-root []
(if-let [root (git-root)]
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
root
(if-let [common (git-common-dir)]
(let [candidate (str (fs/parent common))]
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
candidate
(exit! 1 "Cannot find SwarmForge project root")))
(exit! 1 "Cannot find SwarmForge project root")))
(exit! 1 "Cannot find SwarmForge project root")))
(defn role []
(or (not-empty (System/getenv "SWARMFORGE_ROLE"))
(exit! 1 "Set SWARMFORGE_ROLE.")))
(defn receive-mode [role-name]
(let [roles (str/split-lines (slurp (str (fs/path (project-root) ".swarmforge" "roles.tsv"))))]
(or (some (fn [line]
(let [fields (str/split line #"\t" -1)]
(when (= role-name (first fields))
(not-empty (get fields 6 "task")))))
roles)
(exit! 1 (str "Unknown role: " role-name)))))
(defn run-helper! [script]
(process/exec (str (fs/path script-dir script))))
(defn -main []
(case (receive-mode (role))
"batch" (run-helper! "done_with_current_batch.sh")
"task" (run-helper! "done_with_current_task.sh")
(exit! 2 (str "INVALID_RECEIVE_MODE: " (receive-mode (role)) " for role " (role)))))
(-main)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/done_with_current.bb" "$@"
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bb
(ns done-with-current-batch
(:require [babashka.fs :as fs]
[babashka.process :as process]
[clojure.string :as str]))
(def script-dir (fs/parent *file*))
(defn inbox-dir []
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
(defn timestamp []
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
(java.time.Instant/now)))
(defn handoff-files [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn batch-dirs [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn set-header! [file field value]
(let [lines (str/split-lines (slurp (str file)))
prefix (str field ": ")
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
result (loop [remaining lines
out []
inserted? false
replaced? false]
(if-let [line (first remaining)]
(cond
(and (not inserted?) (str/blank? line))
(recur (next remaining)
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
true
replaced?)
(and (not inserted?) (str/starts-with? line prefix))
(recur (next remaining) (conj out (str prefix value)) inserted? true)
:else
(recur (next remaining) (conj out line) inserted? replaced?))
(cond-> out
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
(spit (str tmp) (str (str/join "\n" result) "\n"))
(fs/move tmp file {:replace-existing true})))
(defn fail! [status & lines]
(binding [*out* *err*]
(doseq [line lines]
(println line)))
(System/exit status))
(defn run-ready! []
(process/exec (str (fs/path script-dir "ready_for_next_batch.sh"))))
(defn -main []
(let [inbox (inbox-dir)
in-process-dir (fs/path inbox "in_process")
completed-dir (fs/path inbox "completed")]
(doseq [dir [in-process-dir completed-dir]]
(fs/create-dirs dir))
(let [in-process-batches (batch-dirs in-process-dir)
in-process-files (handoff-files in-process-dir)]
(when (seq in-process-files)
(fail! 2
"CURRENT_WORK_IS_SINGLE_TASK: use done_with_current.sh."
(str/join "\n" (map #(str "- " %) in-process-files))))
(when (empty? in-process-batches)
(fail! 1 "NO_CURRENT_BATCH"))
(when (> (count in-process-batches) 1)
(fail! 2
"AMBIGUOUS_TASK_STATE: multiple batches are in process."
(str/join "\n" (map #(str "- " %) in-process-batches))))
(let [source-dir (first in-process-batches)
batch-files (handoff-files source-dir)
target-dir (fs/path completed-dir (fs/file-name source-dir))
completed-at (timestamp)]
(when (empty? batch-files)
(fail! 2 (str "AMBIGUOUS_TASK_STATE: batch contains no tasks: " source-dir)))
(when (fs/exists? target-dir)
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed batch already exists: " target-dir)))
(fs/create-dir target-dir)
(doseq [source-file batch-files]
(set-header! source-file "completed_at" completed-at)
(let [target-file (fs/path target-dir (fs/file-name source-file))]
(when (fs/exists? target-file)
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed batch file already exists: " target-file)))
(fs/move source-file target-file)
(println "COMPLETED:" (str target-file))))
(fs/delete source-dir)
(println "COMPLETED_BATCH:" (str target-dir))
(run-ready!)))))
(-main)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/done_with_current_batch.bb" "$@"
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bb
(ns done-with-current-task
(:require [babashka.fs :as fs]
[babashka.process :as process]
[clojure.string :as str]))
(def script-dir (fs/parent *file*))
(defn inbox-dir []
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
(defn timestamp []
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
(java.time.Instant/now)))
(defn handoff-files [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn batch-dirs [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn set-header! [file field value]
(let [lines (str/split-lines (slurp (str file)))
prefix (str field ": ")
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
result (loop [remaining lines
out []
inserted? false
replaced? false]
(if-let [line (first remaining)]
(cond
(and (not inserted?) (str/blank? line))
(recur (next remaining)
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
true
replaced?)
(and (not inserted?) (str/starts-with? line prefix))
(recur (next remaining) (conj out (str prefix value)) inserted? true)
:else
(recur (next remaining) (conj out line) inserted? replaced?))
(cond-> out
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
(spit (str tmp) (str (str/join "\n" result) "\n"))
(fs/move tmp file {:replace-existing true})))
(defn fail! [status & lines]
(binding [*out* *err*]
(doseq [line lines]
(println line)))
(System/exit status))
(defn run-ready! []
(process/exec (str (fs/path script-dir "ready_for_next_task.sh"))))
(defn -main []
(let [inbox (inbox-dir)
in-process-dir (fs/path inbox "in_process")
completed-dir (fs/path inbox "completed")]
(doseq [dir [in-process-dir completed-dir]]
(fs/create-dirs dir))
(let [in-process-batches (batch-dirs in-process-dir)
in-process-files (handoff-files in-process-dir)]
(when (seq in-process-batches)
(fail! 2
"CURRENT_WORK_IS_BATCH: use done_with_current.sh."
(str/join "\n" (map #(str "- " %) in-process-batches))))
(when (empty? in-process-files)
(fail! 1 "NO_CURRENT_TASK"))
(when (> (count in-process-files) 1)
(fail! 2
"AMBIGUOUS_TASK_STATE: multiple tasks are in process."
(str/join "\n" (map #(str "- " %) in-process-files))))
(let [source-file (first in-process-files)
target-file (fs/path completed-dir (fs/file-name source-file))]
(set-header! source-file "completed_at" (timestamp))
(when (fs/exists? target-file)
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed file already exists: " target-file)))
(fs/move source-file target-file)
(println "COMPLETED:" (str target-file))
(run-ready!)))))
(-main)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/done_with_current_task.bb" "$@"
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bb
(ns handoff-lib
(:require [babashka.fs :as fs]
[babashka.process]
[clojure.string :as str]))
(defn role []
(or (System/getenv "SWARMFORGE_ROLE")
(throw (ex-info "Set SWARMFORGE_ROLE." {:exit 1}))))
(defn state-dir []
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
(defn inbox-dir []
(fs/path (state-dir) "inbox"))
(defn project-root []
(let [cwd (fs/cwd)
direct (fs/path cwd ".swarmforge" "roles.tsv")]
(if (fs/exists? direct)
cwd
(let [git-root (:out (babashka.process/sh {:continue true} "git" "rev-parse" "--show-toplevel"))
root (when-not (str/blank? git-root) (fs/path (str/trim git-root)))]
(if (and root (fs/exists? (fs/path root ".swarmforge" "roles.tsv")))
root
(let [common (:out (babashka.process/sh {:continue true} "git" "rev-parse" "--git-common-dir"))
common-path (when-not (str/blank? common)
(let [path (fs/path (str/trim common))]
(if (fs/absolute? path) path (fs/absolutize path))))
common-parent (some-> common-path fs/parent)]
(if (and common-parent (fs/exists? (fs/path common-parent ".swarmforge" "roles.tsv")))
common-parent
(throw (ex-info "Cannot find SwarmForge project root" {:exit 1})))))))))
(defn roles-file []
(fs/path (project-root) ".swarmforge" "roles.tsv"))
(defn role-rows []
(->> (str/split-lines (slurp (str (roles-file))))
(map #(str/split % #"\t" -1))))
(defn role-row [role-name]
(or (some #(when (= role-name (first %)) %) (role-rows))
(throw (ex-info (str "Unknown role: " role-name) {:exit 1}))))
(defn role-known? [role-name]
(boolean (some #(= role-name (first %)) (role-rows))))
(defn role-worktree-name [role-name]
(second (role-row role-name)))
(defn role-receive-mode [role-name]
(let [mode (nth (role-row role-name) 6 "")]
(if (str/blank? mode) "task" mode)))
(defn timestamp []
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
(java.time.Instant/now)))
(defn id-timestamp []
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
(java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)))
(defn valid-priority? [value]
(boolean (re-matches #"[0-9][0-9]" value)))
(defn header-field [file field]
(let [prefix (str field ": ")]
(some (fn [line]
(when (str/starts-with? line prefix)
(subs line (count prefix))))
(take-while (complement str/blank?)
(str/split-lines (slurp (str file)))))))
(defn body [file]
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
(or body "")))
(defn set-header! [file field value]
(let [file (fs/path file)
lines (str/split-lines (slurp (str file)))
prefix (str field ": ")
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
result (loop [remaining lines
out []
inserted? false
replaced? false]
(if-let [line (first remaining)]
(cond
(and (not inserted?) (str/blank? line))
(recur (next remaining)
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
true
replaced?)
(and (not inserted?) (str/starts-with? line prefix))
(recur (next remaining) (conj out (str prefix value)) inserted? true)
:else
(recur (next remaining) (conj out line) inserted? replaced?))
(cond-> out
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
(spit (str tmp) (str (str/join "\n" result) "\n"))
(fs/move tmp file {:replace-existing true})))
(defn print-task [file]
(let [task-name (header-field file "task")]
(println "TASK:" (str file))
(println "FROM:" (or (header-field file "from") "unknown"))
(println "TYPE:" (or (header-field file "type") "unknown"))
(println "PRIORITY:" (or (header-field file "priority") "50"))
(when task-name
(println "TASK_NAME:" task-name))
(println "PAYLOAD:")
(print (body file))))
(defn handoff-files [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn print-batch [batch-dir]
(let [files (handoff-files batch-dir)]
(when (empty? files)
(throw (ex-info (str "AMBIGUOUS_TASK_STATE: batch contains no tasks: " batch-dir) {:exit 2})))
(println "BATCH:" (str batch-dir))
(println "COUNT:" (count files))
(println "PRIORITY:" (or (header-field (first files) "priority") "50"))
(doseq [[index file] (map-indexed vector files)]
(println)
(println "BATCH_ITEM:" (inc index))
(print-task file))))
(defn next-sequence []
(let [dir (state-dir)
seq-file (fs/path dir "sequence")
lock-dir (fs/path dir "sequence.lock")]
(fs/create-dirs dir)
(loop []
(when-not (try (fs/create-dir lock-dir) true (catch Exception _ false))
(Thread/sleep 50)
(recur)))
(try
(let [last-value (if (fs/exists? seq-file)
(str/trim (slurp (str seq-file)))
"0")
last-number (if (re-matches #"[0-9]+" last-value)
(Long/parseLong last-value)
0)
next-number (inc last-number)]
(spit (str seq-file) (format "%06d\n" next-number))
(format "%06d" next-number))
(finally
(fs/delete-tree lock-dir)))))
(defn -main [& args]
(try
(case (first args)
"role" (println (role))
"state-dir" (println (state-dir))
"inbox-dir" (println (inbox-dir))
"project-root" (println (project-root))
"role-known" (System/exit (if (role-known? (second args)) 0 1))
"role-worktree-name" (println (role-worktree-name (second args)))
"role-receive-mode" (println (role-receive-mode (second args)))
"timestamp" (println (timestamp))
"id-timestamp" (println (id-timestamp))
"valid-priority" (System/exit (if (valid-priority? (second args)) 0 1))
"header-field" (if-let [value (header-field (second args) (nth args 2))]
(println value)
(System/exit 1))
"body" (print (body (second args)))
"set-header" (set-header! (second args) (nth args 2) (nth args 3))
"print-task" (print-task (second args))
"print-batch" (print-batch (second args))
"next-sequence" (println (next-sequence))
(do
(binding [*out* *err*]
(println "Usage: handoff_lib.bb <command> [args...]"))
(System/exit 2)))
(catch clojure.lang.ExceptionInfo e
(binding [*out* *err*]
(println (ex-message e)))
(System/exit (or (:exit (ex-data e)) 1)))))
(apply -main *command-line-args*)
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bb
(ns handoffd
(:require [babashka.fs :as fs]
[clojure.java.io :as io]
[clojure.java.shell :refer [sh]]
[clojure.string :as str]))
(def poll-ms 1000)
(def wake-message
"You have new handoff mail. If idle, run ready_for_next.sh.")
(defn usage []
(binding [*out* *err*]
(println "Usage: handoffd.bb <project-root>"))
(System/exit 1))
(def project-root
(or (first *command-line-args*) (usage)))
(def state-dir (fs/path project-root ".swarmforge"))
(def daemon-dir (fs/path state-dir "daemon"))
(def roles-file (fs/path state-dir "roles.tsv"))
(def socket-file (fs/path state-dir "tmux-socket"))
(def pid-file (fs/path daemon-dir "handoffd.pid"))
(def stop-file (fs/path daemon-dir "stop"))
(def log-file (fs/path daemon-dir "handoffd.log"))
(def stopping-flag (atom false))
(defn now []
(.format (java.time.format.DateTimeFormatter/ISO_INSTANT)
(java.time.Instant/now)))
(defn log! [& parts]
(fs/create-dirs daemon-dir)
(spit (str log-file)
(str (now) " " (str/join " " parts) "\n")
:append true))
(defn read-lines [path]
(when (fs/exists? path)
(str/split-lines (slurp (str path)))))
(defn load-roles []
(into {}
(for [line (read-lines roles-file)
:when (not (str/blank? line))
:let [[role worktree-name worktree-path session display agent receive-mode]
(str/split line #"\t")]]
[role {:role role
:worktree-name worktree-name
:worktree-path worktree-path
:session session
:display display
:agent agent
:receive-mode (or receive-mode "task")}])))
(defn parse-message [path]
(let [content (slurp (str path))
[header body] (str/split content #"\n\n" 2)
headers (into {}
(for [line (str/split-lines header)
:let [[k v] (str/split line #": " 2)]
:when (and k v)]
[k v]))]
{:headers headers
:body (or body "")
:content content}))
(defn render-message [headers body]
(let [preferred ["id" "from" "to" "recipient" "priority" "type" "role" "commit"
"message" "created_at" "enqueued_at" "dequeued_at" "completed_at"]
remaining (->> (keys headers)
(remove (set preferred))
sort)
ordered (concat preferred remaining)]
(str (str/join "\n"
(for [k ordered
:let [v (get headers k)]
:when v]
(str k ": " v)))
"\n\n"
body)))
(defn add-delivery-headers [message recipient]
(-> message
(assoc-in [:headers "recipient"] recipient)
(assoc-in [:headers "enqueued_at"] (now))))
(defn target-path [role-info filename]
(fs/path (:worktree-path role-info)
".swarmforge" "handoffs" "inbox" "new" filename))
(defn notify! [socket session]
;; Wake-up universal: el CR va EMBEBIDO en el mismo write que el texto
;; (pi-tui descarta un C-m suelto; texto+\r en un solo send-keys -l
;; dispara el submit en pi). El C-j final es la "robustez" original de
;; upstream para otros TUIs (claude/codex/grok); en pi es inofensivo
;; (nueva línea en el editor vacío tras el submit).
(let [send-text (sh "tmux" "-S" socket "send-keys" "-t" session "-l" (str wake-message "\r"))
_ (Thread/sleep 300)
send-line-feed (sh "tmux" "-S" socket "send-keys" "-t" session "C-j")]
(when-not (zero? (:exit send-text))
(throw (ex-info "tmux send text failed" send-text)))
(when-not (zero? (:exit send-line-feed))
(throw (ex-info "tmux send line feed failed" send-line-feed)))))
(defn move-with-collision [source target-dir]
(fs/create-dirs target-dir)
(let [base (fs/file-name source)
target (fs/path target-dir base)]
(if (fs/exists? target)
(fs/move source
(fs/path target-dir (str (now) "_" base))
{:replace-existing false})
(fs/move source target {:replace-existing false}))))
(defn fail! [path reason]
(let [failed-dir (fs/path (fs/parent (fs/parent path)) "failed")]
(log! "failed" (str path) reason)
(spit (str path ".error") (str reason "\n"))
(move-with-collision path failed-dir)))
(defn deliver! [roles socket sender-role path]
(let [filename (fs/file-name path)
message (parse-message path)
headers (:headers message)
recipients (some-> (get headers "to") (str/split #",") seq)]
(if-not recipients
(fail! path "missing to header")
(do
(doseq [recipient recipients]
(let [role-info (get roles recipient)]
(when-not role-info
(throw (ex-info (str "unknown recipient " recipient) {:recipient recipient})))
(let [target (target-path role-info filename)
delivered (add-delivery-headers message recipient)]
(fs/create-dirs (fs/parent target))
(when-not (fs/exists? target)
(spit (str target) (render-message (:headers delivered) (:body delivered))))
(notify! socket (:session role-info)))))
(move-with-collision path
(fs/path (get-in roles [sender-role :worktree-path])
".swarmforge" "handoffs" "sent"))
(log! "delivered" (str path))))))
(defn outbox-files [role-info]
(let [outbox (fs/path (:worktree-path role-info) ".swarmforge" "handoffs" "outbox")]
(when (fs/exists? outbox)
(->> (fs/list-dir outbox)
(filter #(and (fs/regular-file? %)
(str/ends-with? (fs/file-name %) ".handoff")))
(sort-by #(fs/file-name %))))))
(defn should-stop? []
(or @stopping-flag (fs/exists? stop-file)))
(defn sleep-poll! [ms]
(loop [remaining ms]
(when (and (pos? remaining) (not (should-stop?)))
(let [step (min remaining 100)]
(Thread/sleep step)
(recur (- remaining step))))))
(defn poll-once! []
(when-not (should-stop?)
(let [roles (load-roles)
socket (str/trim (slurp (str socket-file)))]
(doseq [[role role-info] roles
path (or (outbox-files role-info) [])
:while (not (should-stop?))]
(try
(deliver! roles socket role path)
(catch Exception e
(log! "error" (str path) (.getMessage e))
(try
(fail! path (.getMessage e))
(catch Exception nested
(log! "failed-to-archive" (str path) (.getMessage nested))))))))))
(defn shutdown! []
(reset! stopping-flag true)
(try
(fs/delete-if-exists pid-file)
(log! "stopped")
(catch Exception _ nil)))
(defn -main []
(fs/create-dirs daemon-dir)
(fs/delete-if-exists stop-file)
(spit (str pid-file) (str (.pid (java.lang.ProcessHandle/current)) "\n"))
(.addShutdownHook (Runtime/getRuntime) (Thread. shutdown!))
(log! "started")
(try
(while (not (should-stop?))
(poll-once!)
(sleep-poll! poll-ms))
(finally
(fs/delete-if-exists pid-file)
(log! "stopped"))))
(-main)
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bb
(ns ready-for-next
(:require [babashka.fs :as fs]
[babashka.process :as process]
[clojure.java.shell :as sh]
[clojure.string :as str]))
(def script-dir (fs/parent *file*))
(defn exit! [status message]
(binding [*out* *err*]
(println message))
(System/exit status))
(defn command [& args]
(apply sh/sh args))
(defn git-root []
(let [result (command "git" "rev-parse" "--show-toplevel")]
(when (zero? (:exit result))
(str/trim (:out result)))))
(defn git-common-dir []
(let [result (command "git" "rev-parse" "--git-common-dir")]
(when (zero? (:exit result))
(let [path (str/trim (:out result))]
(if (fs/absolute? path)
path
(str (fs/absolutize path)))))))
(defn project-root []
(if-let [root (git-root)]
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
root
(if-let [common (git-common-dir)]
(let [candidate (str (fs/parent common))]
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
candidate
(exit! 1 "Cannot find SwarmForge project root")))
(exit! 1 "Cannot find SwarmForge project root")))
(exit! 1 "Cannot find SwarmForge project root")))
(defn role []
(or (not-empty (System/getenv "SWARMFORGE_ROLE"))
(exit! 1 "Set SWARMFORGE_ROLE.")))
(defn receive-mode [role-name]
(let [roles (str/split-lines (slurp (str (fs/path (project-root) ".swarmforge" "roles.tsv"))))]
(or (some (fn [line]
(let [fields (str/split line #"\t" -1)]
(when (= role-name (first fields))
(not-empty (get fields 6 "task")))))
roles)
(exit! 1 (str "Unknown role: " role-name)))))
(defn run-helper! [script]
(process/exec (str (fs/path script-dir script))))
(defn -main []
(case (receive-mode (role))
"batch" (run-helper! "ready_for_next_batch.sh")
"task" (run-helper! "ready_for_next_task.sh")
(exit! 2 (str "INVALID_RECEIVE_MODE: " (receive-mode (role)) " for role " (role)))))
(-main)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/ready_for_next.bb" "$@"
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bb
(ns ready-for-next-batch
(:require [babashka.fs :as fs]
[clojure.string :as str]))
(defn inbox-dir []
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
(defn timestamp []
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
(java.time.Instant/now)))
(defn id-timestamp []
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
(java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)))
(defn handoff-files [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn batch-dirs [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn header-field [file field]
(let [prefix (str field ": ")]
(some (fn [line]
(when (str/starts-with? line prefix)
(subs line (count prefix))))
(take-while (complement str/blank?) (str/split-lines (slurp (str file)))))))
(defn header-value [file field default]
(or (header-field file field) default))
(defn body [file]
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
(or body "")))
(defn set-header! [file field value]
(let [lines (str/split-lines (slurp (str file)))
prefix (str field ": ")
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
result (loop [remaining lines
out []
inserted? false
replaced? false]
(if-let [line (first remaining)]
(cond
(and (not inserted?) (str/blank? line))
(recur (next remaining)
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
true
replaced?)
(and (not inserted?) (str/starts-with? line prefix))
(recur (next remaining) (conj out (str prefix value)) inserted? true)
:else
(recur (next remaining) (conj out line) inserted? replaced?))
(cond-> out
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
(spit (str tmp) (str (str/join "\n" result) "\n"))
(fs/move tmp file {:replace-existing true})))
(defn print-task [file]
(let [task-name (header-field file "task")]
(println "TASK:" (str file))
(println "FROM:" (header-value file "from" "unknown"))
(println "TYPE:" (header-value file "type" "unknown"))
(println "PRIORITY:" (header-value file "priority" "50"))
(when task-name
(println "TASK_NAME:" task-name))
(println "PAYLOAD:")
(print (body file))))
(defn print-batch [batch-dir]
(let [files (handoff-files batch-dir)]
(when (empty? files)
(binding [*out* *err*]
(println "AMBIGUOUS_TASK_STATE: batch contains no tasks:" (str batch-dir)))
(System/exit 2))
(println "BATCH:" (str batch-dir))
(println "COUNT:" (count files))
(println "PRIORITY:" (header-value (first files) "priority" "50"))
(doseq [[index file] (map-indexed vector files)]
(println)
(println "BATCH_ITEM:" (inc index))
(print-task file))))
(defn fail! [status & lines]
(binding [*out* *err*]
(doseq [line lines]
(println line)))
(System/exit status))
(defn new-batch-dir [in-process-dir]
(loop [suffix 1]
(let [dir (fs/path in-process-dir (format "batch_%s_%06d" (id-timestamp) suffix))]
(if (fs/exists? dir)
(recur (inc suffix))
dir))))
(defn -main []
(let [inbox (inbox-dir)
new-dir (fs/path inbox "new")
in-process-dir (fs/path inbox "in_process")
completed-dir (fs/path inbox "completed")]
(doseq [dir [new-dir in-process-dir completed-dir]]
(fs/create-dirs dir))
(let [in-process-batches (batch-dirs in-process-dir)
in-process-files (handoff-files in-process-dir)]
(when (seq in-process-files)
(fail! 2
"TASK_IN_PROCESS_IS_SINGLE: use ready_for_next.sh or done_with_current.sh."
(str/join "\n" (map #(str "- " %) in-process-files))))
(when (> (count in-process-batches) 1)
(fail! 2
"AMBIGUOUS_TASK_STATE: multiple batches are already in process."
(str/join "\n" (map #(str "- " %) in-process-batches))))
(if (= 1 (count in-process-batches))
(print-batch (first in-process-batches))
(let [new-files (handoff-files new-dir)]
(if (empty? new-files)
(println "NO_TASK")
(let [batch-priority (header-value (first new-files) "priority" "50")
batch-dir (new-batch-dir in-process-dir)
selected-files (filter #(= batch-priority (header-value % "priority" "50")) new-files)]
(fs/create-dir batch-dir)
(doseq [source-file selected-files]
(let [target-file (fs/path batch-dir (fs/file-name source-file))]
(when (fs/exists? target-file)
(fail! 2 (str "AMBIGUOUS_TASK_STATE: target batch file already exists: " target-file)))
(fs/move source-file target-file)
(set-header! target-file "dequeued_at" (timestamp))))
(when (empty? selected-files)
(fail! 2 (str "AMBIGUOUS_TASK_STATE: no tasks selected for batch priority " batch-priority ".")))
(print-batch batch-dir))))))))
(-main)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/ready_for_next_batch.bb" "$@"
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bb
(ns ready-for-next-task
(:require [babashka.fs :as fs]
[clojure.string :as str]))
(defn state-dir []
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
(defn inbox-dir []
(fs/path (state-dir) "inbox"))
(defn timestamp []
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
(java.time.Instant/now)))
(defn handoff-files [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn batch-dirs [dir]
(if (fs/exists? dir)
(->> (fs/list-dir dir)
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
(sort-by #(fs/file-name %))
vec)
[]))
(defn header-field [file field]
(let [prefix (str field ": ")]
(some (fn [line]
(when (str/starts-with? line prefix)
(subs line (count prefix))))
(take-while (complement str/blank?) (str/split-lines (slurp (str file)))))))
(defn header-value [file field default]
(or (header-field file field) default))
(defn body [file]
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
(or body "")))
(defn set-header! [file field value]
(let [lines (str/split-lines (slurp (str file)))
prefix (str field ": ")
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
result (loop [remaining lines
out []
inserted? false
replaced? false]
(if-let [line (first remaining)]
(cond
(and (not inserted?) (str/blank? line))
(recur (next remaining)
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
true
replaced?)
(and (not inserted?) (str/starts-with? line prefix))
(recur (next remaining) (conj out (str prefix value)) inserted? true)
:else
(recur (next remaining) (conj out line) inserted? replaced?))
(cond-> out
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
(spit (str tmp) (str (str/join "\n" result) "\n"))
(fs/move tmp file {:replace-existing true})))
(defn print-task [file]
(let [task-name (header-field file "task")]
(println "TASK:" (str file))
(println "FROM:" (header-value file "from" "unknown"))
(println "TYPE:" (header-value file "type" "unknown"))
(println "PRIORITY:" (header-value file "priority" "50"))
(when task-name
(println "TASK_NAME:" task-name))
(println "PAYLOAD:")
(print (body file))))
(defn fail! [status & lines]
(binding [*out* *err*]
(doseq [line lines]
(println line)))
(System/exit status))
(defn -main []
(let [inbox (inbox-dir)
new-dir (fs/path inbox "new")
in-process-dir (fs/path inbox "in_process")
completed-dir (fs/path inbox "completed")]
(doseq [dir [new-dir in-process-dir completed-dir]]
(fs/create-dirs dir))
(let [in-process-batches (batch-dirs in-process-dir)
in-process-files (handoff-files in-process-dir)]
(when (seq in-process-batches)
(fail! 2
"TASK_IN_PROCESS_IS_BATCH: use ready_for_next.sh or done_with_current.sh."
(str/join "\n" (map #(str "- " %) in-process-batches))))
(when (> (count in-process-files) 1)
(fail! 2
"AMBIGUOUS_TASK_STATE: multiple tasks are already in process."
(str/join "\n" (map #(str "- " %) in-process-files))))
(if (= 1 (count in-process-files))
(print-task (first in-process-files))
(let [new-files (handoff-files new-dir)]
(if (empty? new-files)
(println "NO_TASK")
(let [source-file (first new-files)
target-file (fs/path in-process-dir (fs/file-name source-file))]
(when (fs/exists? target-file)
(fail! 2 (str "AMBIGUOUS_TASK_STATE: target in-process file already exists: " target-file)))
(fs/move source-file target-file)
(set-header! target-file "dequeued_at" (timestamp))
(print-task target-file))))))))
(-main)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/ready_for_next_task.bb" "$@"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bb
(ns stop-handoff-daemon
(:require [babashka.fs :as fs]
[babashka.process :as process]
[clojure.string :as str]))
(def default-timeout-ms 5000)
(def poll-ms 100)
(defn usage []
(binding [*out* *err*]
(println "Usage: stop_handoff_daemon.bb <project-root>"))
(System/exit 1))
(defn process-alive? [pid]
(zero? (:exit (process/sh {:continue true} "kill" "-0" pid))))
(defn stop! [project-root & {:keys [timeout-ms] :or {timeout-ms default-timeout-ms}}]
(let [daemon-dir (fs/path project-root ".swarmforge" "daemon")
pid-file (fs/path daemon-dir "handoffd.pid")
stop-file (fs/path daemon-dir "stop")]
(fs/create-dirs daemon-dir)
(when-not (fs/exists? stop-file)
(spit (str stop-file) ""))
(when (fs/exists? pid-file)
(let [pid (str/trim (slurp (str pid-file)))]
(when (re-matches #"[0-9]+" pid)
(when (process-alive? pid)
(process/sh {:continue true} "kill" "-TERM" pid)
(loop [waited 0]
(when (and (< waited timeout-ms) (process-alive? pid))
(Thread/sleep poll-ms)
(recur (+ waited poll-ms))))
(when (process-alive? pid)
(process/sh {:continue true} "kill" "-KILL" pid)
(Thread/sleep poll-ms)))))
(fs/delete-if-exists pid-file))
(fs/delete-if-exists stop-file)))
(defn -main [& args]
(stop! (or (first args) (usage)))
(System/exit 0))
(apply -main *command-line-args*)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/stop_handoff_daemon.bb" "$@"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: swarm-cleanup.sh <tmux-socket> <window-ids-file> [session ...]" >&2
exit 1
fi
TMUX_SOCKET="$1"
WINDOW_IDS_FILE="$2"
TERMINAL_BACKEND="${SWARMFORGE_TERMINAL_BACKEND:-terminal-app}"
WORKING_DIR="$(cd "$(dirname "$WINDOW_IDS_FILE")/.." && pwd)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
shift
shift
has_command() {
command -v "$1" &>/dev/null
}
source "$SCRIPT_DIR/swarm-terminal-adapter.sh"
load_terminal_backend "$TERMINAL_BACKEND"
if has_command bb; then
bb "$SCRIPT_DIR/stop_handoff_daemon.bb" "$WORKING_DIR" 2>/dev/null || true
else
DAEMON_PID_FILE="$WORKING_DIR/.swarmforge/daemon/handoffd.pid"
if [[ -f "$DAEMON_PID_FILE" ]]; then
daemon_pid="$(< "$DAEMON_PID_FILE")"
if [[ "$daemon_pid" =~ ^[0-9]+$ ]]; then
kill -TERM "$daemon_pid" 2>/dev/null || true
fi
rm -f "$DAEMON_PID_FILE"
fi
fi
for session in "$@"; do
tmux -S "$TMUX_SOCKET" kill-session -t "$session" 2>/dev/null || true
done
sleep 1
if [[ -f "$WINDOW_IDS_FILE" ]]; then
while IFS= read -r window_id; do
[[ -n "$window_id" ]] || continue
terminal_close_window "$window_id"
done < "$WINDOW_IDS_FILE"
fi
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
TERMINAL_ADAPTERS_DIR="${SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}/terminal-adapters"
normalize_terminal_backend() {
local backend="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "$backend" in
iterm|iterm2|iterm.app)
echo "iterm2"
;;
terminal|terminal-app|terminal.app)
echo "terminal-app"
;;
windows|windows-terminal|wt)
echo "windows-terminal"
;;
none|current|fallback)
echo "none"
;;
*)
echo "$backend"
;;
esac
}
detect_terminal_backend() {
if [[ -n "${SWARMFORGE_TERMINAL:-}" ]]; then
normalize_terminal_backend "$SWARMFORGE_TERMINAL"
return
fi
if has_command osascript; then
if [[ "${TERM_PROGRAM:-}" == "iTerm.app" ]]; then
echo "iterm2"
return
fi
echo "terminal-app"
return
fi
if has_command wt.exe; then
echo "windows-terminal"
return
fi
echo "none"
}
load_terminal_backend() {
local backend="$1"
local adapter_file="$TERMINAL_ADAPTERS_DIR/$backend.sh"
if [[ ! -r "$adapter_file" ]]; then
echo "Unknown terminal backend '$backend'. Expected adapter file: $adapter_file" >&2
return 1
fi
source "$adapter_file"
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bb
(ns swarm-window-watchdog
(:require [babashka.fs :as fs]
[babashka.process :as process]
[clojure.string :as str]))
(def missing-threshold 3)
(defn sq [value]
(str "'" (str/replace (str value) #"'" "'\"'\"'") "'"))
(defn rows [window-state-file]
(when (fs/exists? window-state-file)
(->> (str/split-lines (slurp (str window-state-file)))
(remove str/blank?)
(map #(zipmap [:index :window-id :session :title]
(str/split % #"\t" -1)))
vec)))
(defn write-rows! [window-state-file window-ids-file rows]
(spit (str window-state-file)
(apply str
(for [{:keys [index window-id session title]} rows]
(format "%s\t%s\t%s\t%s\n" index window-id session title))))
(spit (str window-ids-file)
(apply str (for [{:keys [window-id]} rows] (str window-id "\n")))))
(defn rewrite-window-id! [window-state-file window-ids-file target-index replacement-id]
(write-rows! window-state-file
window-ids-file
(mapv #(if (= (:index %) target-index)
(assoc % :window-id replacement-id)
%)
(rows window-state-file))))
(defn adapter-script [script-dir working-dir tmux-socket backend command & args]
(let [script (str "SCRIPT_DIR=" (sq (str script-dir)) "\n"
"WORKING_DIR=" (sq (str working-dir)) "\n"
"TMUX_SOCKET=" (sq tmux-socket) "\n"
"source " (sq (str (fs/path script-dir "swarm-terminal-adapter.sh")))
" && load_terminal_backend " (sq backend)
" && " command
(apply str (map #(str " " (sq %)) args)))]
["bash" "-c" script]))
(defn terminal-ok? [script-dir working-dir tmux-socket backend command & args]
(zero? (:exit (apply process/sh (concat [{:continue true}]
(apply adapter-script script-dir working-dir tmux-socket backend command args))))))
(defn terminal-out [script-dir working-dir tmux-socket backend command & args]
(str/trim (:out (apply process/sh (apply adapter-script script-dir working-dir tmux-socket backend command args)))))
(defn tmux-session? [tmux-socket session]
(zero? (:exit (process/sh {:continue true} "tmux" "-S" tmux-socket "has-session" "-t" session))))
(defn kill-session! [tmux-socket session]
(process/sh {:continue true} "tmux" "-S" tmux-socket "kill-session" "-t" session))
(defn stop-handoff-daemon! [script-dir working-dir]
(process/sh {:continue true}
"bb" (str (fs/path script-dir "stop_handoff_daemon.bb"))
(str working-dir)))
(defn kill-all-sessions! [script-dir window-state-file working-dir tmux-socket backend]
(stop-handoff-daemon! script-dir working-dir)
(doseq [{:keys [session]} (rows window-state-file)]
(when-not (str/blank? session)
(kill-session! tmux-socket session)))
(doseq [{:keys [window-id]} (rows window-state-file)]
(when-not (str/blank? window-id)
(terminal-ok? script-dir working-dir tmux-socket backend "terminal_close_window" window-id))))
(defn -main [& args]
(let [[window-state-file window-ids-file cleanup-owner-index tmux-socket working-dir backend] args
window-state-file (fs/path window-state-file)
window-ids-file (fs/path window-ids-file)
backend (or backend "terminal-app")
script-dir (fs/parent *file*)]
(when (= "--rewrite-window-id" (first args))
(let [[_ state ids target replacement] args]
(rewrite-window-id! (fs/path state) (fs/path ids) target replacement)
(System/exit 0)))
(loop [missing-counts {}]
(when (fs/exists? window-state-file)
(let [current-rows (rows window-state-file)
cleanup-row (some #(when (= cleanup-owner-index (:index %)) %) current-rows)]
(when (and cleanup-row (tmux-session? tmux-socket (:session cleanup-row)))
(let [cleanup-window-id (:window-id cleanup-row)]
(if (terminal-ok? script-dir working-dir tmux-socket backend "terminal_window_exists" cleanup-window-id)
(let [missing-counts (assoc missing-counts cleanup-owner-index 0)
missing-counts
(reduce
(fn [counts {:keys [index window-id session title]}]
(if (or (= index cleanup-owner-index)
(not (tmux-session? tmux-socket session)))
counts
(if (terminal-ok? script-dir working-dir tmux-socket backend "terminal_window_exists" window-id)
(assoc counts index 0)
(let [count (inc (get counts index 0))]
(if (< count missing-threshold)
(assoc counts index count)
(let [new-window-id (terminal-out script-dir working-dir tmux-socket backend
"terminal_open_session" session title cleanup-window-id)]
(when-not (str/blank? new-window-id)
(rewrite-window-id! window-state-file window-ids-file index new-window-id))
(assoc counts index 0)))))))
missing-counts
current-rows)]
(Thread/sleep 2000)
(recur missing-counts))
(let [count (inc (get missing-counts cleanup-owner-index 0))]
(if (>= count missing-threshold)
(kill-all-sessions! script-dir window-state-file working-dir tmux-socket backend)
(do
(Thread/sleep 2000)
(recur (assoc missing-counts cleanup-owner-index count)))))))))))))
(apply -main *command-line-args*)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/swarm-window-watchdog.bb" "$@"
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env bb
(ns swarm-handoff
(:require [babashka.fs :as fs]
[clojure.java.shell :refer [sh]]
[clojure.string :as str]))
(def usage-text
(str "Usage: swarm_handoff.sh <draft-file>\n\n"
"Draft formats:\n\n"
"type: git_handoff\n"
"to: <role>[,<role>...]\n"
"priority: NN\n"
"task: <short-stable-task-name>\n"
"commit: <10-char-commit-abbrev>\n\n"
"type: note\n"
"to: <role>[,<role>...]\n"
"priority: NN\n"
"message: <one line, max 80 chars>"))
(def reserved-fields #{"id" "from" "role" "recipient" "created_at" "enqueued_at" "dequeued_at" "completed_at"})
(def allowed-fields #{"type" "to" "priority" "task" "commit" "message"})
(def allowed-types #{"git_handoff" "note"})
(defn usage []
(binding [*out* *err*]
(println usage-text)))
(defn exit! [status message]
(binding [*out* *err*]
(when message
(println message)))
(System/exit status))
(defn command
([dir & args]
(let [result (apply sh (concat args [:dir (str dir)]))]
result)))
(defn git-root []
(let [result (command "." "git" "rev-parse" "--show-toplevel")]
(when (zero? (:exit result))
(str/trim (:out result)))))
(defn git-common-dir []
(let [result (command "." "git" "rev-parse" "--git-common-dir")]
(when (zero? (:exit result))
(let [path (str/trim (:out result))]
(if (fs/absolute? path)
(str (fs/path path))
(str (fs/absolutize path)))))))
(defn project-root []
(if-let [root (git-root)]
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
root
(if-let [common (git-common-dir)]
(let [candidate (str (fs/parent common))]
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
candidate
(exit! 1 "Cannot find SwarmForge project root")))
(exit! 1 "Cannot find SwarmForge project root")))
(exit! 1 "Cannot find SwarmForge project root")))
(defn roles-file []
(fs/path (project-root) ".swarmforge" "roles.tsv"))
(defn role-known? [role]
(some (fn [line]
(= role (first (str/split line #"\t"))))
(str/split-lines (slurp (str (roles-file))))))
(defn sender-role []
(if-let [role (not-empty (System/getenv "SWARMFORGE_ROLE"))]
role
(exit! 1 "Set SWARMFORGE_ROLE.")))
(defn state-dir []
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
(defn timestamp []
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
(java.time.Instant/now)))
(defn id-timestamp []
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
(.atZone (java.time.Instant/now) java.time.ZoneOffset/UTC)))
(defn valid-priority? [priority]
(boolean (re-matches #"[0-9][0-9]" priority)))
(defn parse-draft [draft]
(loop [lines (str/split-lines (slurp (str draft)))
line-no 0
body-seen? false
headers {}
ordered []
errors []]
(if-let [line (first lines)]
(let [line-no (inc line-no)]
(cond
body-seen?
(recur (next lines) line-no body-seen? headers ordered
(cond-> errors
(not (str/blank? line))
(conj (format "Line %d: draft handoffs may contain headers only; payloads are generated by swarm_handoff.sh." line-no))))
(str/blank? line)
(recur (next lines) line-no true headers ordered errors)
(not (str/includes? line ": "))
(recur (next lines) line-no body-seen? headers ordered
(conj errors (format "Line %d: expected 'field: value'." line-no)))
:else
(let [[field value] (str/split line #": " 2)]
(cond
(or (str/blank? field) (str/blank? value))
(recur (next lines) line-no body-seen? headers ordered
(conj errors (format "Line %d: field and value must both be non-empty." line-no)))
(reserved-fields field)
(recur (next lines) line-no body-seen? headers ordered
(conj errors (format "Line %d: header '%s' is reserved and must not be written by agents." line-no field)))
(not (allowed-fields field))
(recur (next lines) line-no body-seen? headers ordered
(conj errors (format "Line %d: unknown header '%s'." line-no field)))
(contains? headers field)
(recur (next lines) line-no body-seen? headers ordered
(conj errors (format "Line %d: duplicate header '%s'." line-no field)))
:else
(recur (next lines) line-no body-seen? (assoc headers field value) (conj ordered field) errors)))))
{:headers headers :ordered ordered :errors errors})))
(defn validate-recipients [to]
(if (str/blank? to)
[[] []]
(let [recipients (str/split to #"," -1)]
[recipients
(loop [remaining recipients seen #{} errors []]
(if-let [recipient (first remaining)]
(let [errors (cond-> errors
(str/blank? recipient)
(conj "Header 'to' contains an empty recipient.")
(str/includes? recipient "_")
(conj (format "Recipient role '%s' is invalid; role names may not contain underscores." recipient))
(contains? seen recipient)
(conj (format "Duplicate recipient '%s'." recipient))
(and (not (str/blank? recipient)) (not (role-known? recipient)))
(conj (format "Unknown recipient role '%s'." recipient)))]
(recur (next remaining) (conj seen recipient) errors))
errors))])))
(defn canonical-commit [commit]
(let [matches (-> (command "." "git" "rev-parse" (str "--disambiguate=" commit))
:out
str/split-lines
vec)]
(cond
(not= 1 (count matches))
[nil (format "Header 'commit' must resolve to exactly one Git object; '%s' matched %d." commit (count matches))]
:else
(let [object (first matches)
object-type (str/trim (:out (command "." "git" "cat-file" "-t" object)))]
(if (= "commit" object-type)
[(str/trim (:out (command "." "git" "rev-parse" "--short=10" object))) nil]
[nil (format "Header 'commit' must resolve to a commit; '%s' resolves to '%s'." commit object-type)])))))
(defn validate [headers ordered]
(let [type (get headers "type")
to (get headers "to")
priority (get headers "priority")
commit (get headers "commit")
task-name (get headers "task")
note-message (get headers "message")
[recipients recipient-errors] (validate-recipients to)
field-errors (for [field ordered
:let [valid? (case [type field]
["git_handoff" "type"] true
["git_handoff" "to"] true
["git_handoff" "priority"] true
["git_handoff" "task"] true
["git_handoff" "commit"] true
["note" "type"] true
["note" "to"] true
["note" "priority"] true
["note" "message"] true
false)]
:when (and type (not valid?))]
(format "Header '%s' is not allowed for type '%s'." field type))
base-errors (cond-> []
(str/blank? type) (conj "Missing required header 'type'.")
(str/blank? to) (conj "Missing required header 'to'.")
(str/blank? priority) (conj "Missing required header 'priority'.")
(and (not (str/blank? type)) (not (allowed-types type)))
(conj (format "Header 'type' must be one of git_handoff or note; got '%s'." type))
(and (not (str/blank? priority)) (not (valid-priority? priority)))
(conj (format "Header 'priority' must be two digits from 00 to 99; got '%s'." priority)))
[canonical commit-error]
(if (= "git_handoff" type)
(cond
(str/blank? commit) [nil "Missing required header 'commit' for git_handoff."]
(not (re-matches #"[0-9a-fA-F]{10}" commit))
[nil (format "Header 'commit' must be exactly 10 hexadecimal characters; got '%s'." commit)]
:else (canonical-commit commit))
[nil nil])
git-errors (cond-> []
(= "git_handoff" type)
(into (cond-> []
(str/blank? task-name)
(conj "Missing required header 'task' for git_handoff.")
(> (count (or task-name "")) 80)
(conj (format "Header 'task' must be no longer than 80 characters; got %d." (count task-name)))))
(and (not= "git_handoff" type) (not (str/blank? commit)))
(conj "Header 'commit' is only allowed for git_handoff.")
(and (not= "git_handoff" type) (not (str/blank? task-name)))
(conj "Header 'task' is only allowed for git_handoff.")
commit-error
(conj commit-error))
note-errors (cond-> []
(= "note" type)
(into (cond-> []
(str/blank? note-message)
(conj "Missing required header 'message' for note.")
(> (count (or note-message "")) 80)
(conj (format "Header 'message' must be no longer than 80 characters; got %d." (count note-message)))))
(and (not= "note" type) (not (str/blank? note-message)))
(conj "Header 'message' is only allowed for note."))]
{:recipients recipients
:canonical-commit canonical
:errors (vec (concat base-errors recipient-errors field-errors git-errors note-errors))}))
(defn next-sequence []
(let [dir (state-dir)
seq-file (fs/path dir "sequence")
lock-dir (fs/path dir "sequence.lock")]
(fs/create-dirs dir)
(loop []
(if (try
(fs/create-dir lock-dir)
true
(catch java.nio.file.FileAlreadyExistsException _
false))
nil
(do
(Thread/sleep 50)
(recur))))
(try
(let [last-value (if (fs/exists? seq-file)
(try
(Long/parseLong (str/trim (slurp (str seq-file))))
(catch Exception _ 0))
0)
next-value (inc last-value)
formatted (format "%06d" next-value)]
(spit (str seq-file) (str formatted "\n"))
formatted)
(finally
(fs/delete lock-dir)))))
(defn body [type sender canonical-commit note-message]
(case type
"git_handoff" (str "Re-read your role and constitution.\n\nmerge_and_process " sender " " canonical-commit)
"note" (str "Re-read your role and constitution.\n\n" note-message)))
(defn write-handoff! [{:keys [headers recipients canonical-commit sender]}]
(let [timestamp-id (id-timestamp)
created-at (timestamp)
sequence (next-sequence)
id (str timestamp-id "_" sequence "_from_" sender)
recipient-slug (str/join "_" recipients)
priority (get headers "priority")
type (get headers "type")
filename (str priority "_" timestamp-id "_" sequence "_from_" sender "_to_" recipient-slug ".handoff")
outbox-dir (fs/path (state-dir) "outbox")
tmp-dir (fs/path outbox-dir "tmp")
tmp-file (fs/path tmp-dir (str filename ".tmp"))
outbox-file (fs/path outbox-dir filename)
handoff-body (body type sender canonical-commit (get headers "message"))
lines (cond-> [(str "id: " id)
(str "from: " sender)
(str "to: " (str/join "," recipients))
(str "priority: " priority)
(str "type: " type)]
(= "git_handoff" type)
(conj (str "role: " sender)
(str "task: " (get headers "task"))
(str "commit: " canonical-commit))
(= "note" type)
(conj (str "message: " (get headers "message")))
true
(conj (str "created_at: " created-at)
""
handoff-body))]
(doseq [dir [tmp-dir outbox-dir (fs/path (state-dir) "sent") (fs/path (state-dir) "failed")]]
(fs/create-dirs dir))
(spit (str tmp-file) (str (str/join "\n" lines) "\n"))
(fs/move tmp-file outbox-file)
outbox-file))
(defn error-report [draft errors]
(binding [*out* *err*]
(println "HANDOFF INVALID:" (str draft))
(println)
(println "Errors:")
(doseq [error errors]
(println "-" error))
(println)
(println usage-text)))
(defn -main [& args]
(when (not= 1 (count args))
(usage)
(System/exit 1))
(let [draft (fs/path (first args))]
(when-not (fs/regular-file? draft)
(exit! 1 (str "Draft file not found: " draft)))
(let [sender (sender-role)]
(when-not (role-known? sender)
(exit! 1 (str "Unknown sender role: " sender)))
(let [{:keys [headers ordered errors]} (parse-draft draft)
validation (validate headers ordered)
all-errors (vec (concat errors (:errors validation)))]
(when (seq all-errors)
(error-report draft all-errors)
(System/exit 2))
(let [outbox-file (write-handoff! {:headers headers
:recipients (:recipients validation)
:canonical-commit (:canonical-commit validation)
:sender sender})]
(fs/delete draft)
(println "HANDOFF QUEUED:" (str outbox-file)))))))
(apply -main *command-line-args*)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/swarm_handoff.bb" "$@"
+603
View File
@@ -0,0 +1,603 @@
#!/usr/bin/env bb
(ns swarmforge
(:require [babashka.fs :as fs]
[babashka.process :as process]
[clojure.string :as str]))
(def session-prefix "swarmforge")
(def agent-window "swarm")
(def red "\u001b[0;31m")
(def green "\u001b[0;32m")
(def yellow "\u001b[1;33m")
(def cyan "\u001b[0;36m")
(def bold "\u001b[1m")
(def reset "\u001b[0m")
(defn sh [& args]
(apply process/sh args))
(defn sh-ok? [& args]
(zero? (:exit (apply process/sh (concat [{:continue true}] args)))))
(defn sh-out [& args]
(str/trim (:out (apply process/sh args))))
(defn command-exists? [command]
(sh-ok? "sh" "-c" (str "command -v " command " >/dev/null 2>&1")))
(defn env-long [name default-value]
(if-let [value (System/getenv name)]
(if (re-matches #"[0-9]+" value)
(Long/parseLong value)
default-value)
default-value))
(defn fail! [message]
(binding [*out* *err*]
(println message))
(System/exit 1))
(defn sq [value]
(str "'" (str/replace (str value) #"'" "'\"'\"'") "'"))
(defn normalize-terminal-backend [backend]
(case (str/lower-case backend)
("iterm" "iterm2" "iterm.app") "iterm2"
("terminal" "terminal-app" "terminal.app") "terminal-app"
("windows" "windows-terminal" "wt") "windows-terminal"
("none" "current" "fallback") "none"
(str/lower-case backend)))
(defn detect-terminal-backend []
(if-let [backend (System/getenv "SWARMFORGE_TERMINAL")]
(normalize-terminal-backend backend)
(cond
(command-exists? "osascript") (if (= (System/getenv "TERM_PROGRAM") "iTerm.app")
"iterm2"
"terminal-app")
(command-exists? "wt.exe") "windows-terminal"
:else "none")))
(defn display-name-for-role [role]
(->> (str/split (str/replace role #"[-_]" " ") #"\s+")
(remove str/blank?)
(map str/capitalize)
(str/join " ")))
(defn session-name-for-role [role]
(str session-prefix "-" role))
(defn worktree-path-for-name [worktrees-dir worktree]
(fs/path worktrees-dir worktree))
(defn tmux-agent-target [window pane-base-index session]
(str session ":" window "." pane-base-index))
(defn tmux-option [tmux-socket option scope default-value]
(let [args (case scope
:session ["tmux" "-S" tmux-socket "show-options" "-gqv" option]
:window ["tmux" "-S" tmux-socket "show-options" "-gwqv" option])
result (apply process/sh (concat [{:continue true}] args))
value (str/trim (:out result))]
(if (re-matches #"[0-9]+" value)
(Long/parseLong value)
default-value)))
(defn detect-tmux-base-indexes [ctx]
(fs/create-dirs (:tmux-socket-dir ctx))
(let [probe-session (when-not (sh-ok? "tmux" "-S" (:tmux-socket ctx) "info")
(let [session (str "swarmforge-probe-" (.pid (java.lang.ProcessHandle/current)))]
(sh "tmux" "-S" (:tmux-socket ctx) "new-session" "-d" "-s" session "sleep 60")
session))
window-base (tmux-option (:tmux-socket ctx) "base-index" :session 0)
pane-base (tmux-option (:tmux-socket ctx) "pane-base-index" :window 0)]
(when probe-session
(process/sh {:continue true} "tmux" "-S" (:tmux-socket ctx) "kill-session" "-t" probe-session))
(assoc ctx :tmux-window-base-index window-base :tmux-pane-base-index pane-base)))
(defn ensure-in-file! [file pattern]
(fs/create-dirs (fs/parent file))
(when-not (fs/exists? file)
(spit (str file) ""))
(let [lines (set (str/split-lines (slurp (str file))))]
(when-not (contains? lines pattern)
(spit (str file) (str pattern "\n") :append true))))
(defn ensure-initial-gitignore! [ctx]
(let [gitignore (fs/path (:working-dir ctx) ".gitignore")]
(if-not (fs/exists? gitignore)
(spit (str gitignore) ".swarmforge/\n.worktrees/\n")
(do
(ensure-in-file! gitignore ".swarmforge/")
(ensure-in-file! gitignore ".worktrees/")))))
(defn ensure-runtime-git-excludes! [ctx]
(let [exclude-file (fs/path (sh-out "git" "-C" (str (:working-dir ctx)) "rev-parse" "--git-path" "info/exclude"))]
(fs/create-dirs (fs/parent exclude-file))
(ensure-in-file! exclude-file ".swarmforge/")
(ensure-in-file! exclude-file ".worktrees/")))
(defn git-identity-ok? [dir]
(and (sh-ok? "git" "-C" dir "config" "user.name")
(sh-ok? "git" "-C" dir "config" "user.email")))
(defn initialize-git-repo! [ctx]
(when-not (fs/exists? (fs/path (:working-dir ctx) ".git"))
(let [dir (str (:working-dir ctx))]
(when-not (git-identity-ok? dir)
(fail! (str red "Error:" reset " Git identity not configured. Run:"
"\n git config --global user.name \"Your Name\""
"\n git config --global user.email \"you@example.com\"")))
(sh "git" "init" dir)
(sh "git" "-C" dir "branch" "-M" "master")
(ensure-initial-gitignore! ctx)
(sh "git" "-C" dir "add" ".")
(sh "git" "-C" dir "commit" "-m" "Initial swarmforge repository"))))
(defn parse-config [ctx]
(when-not (fs/exists? (:config-file ctx))
(fail! (str red "Error:" reset " Config not found at " (:config-file ctx))))
(when-not (fs/exists? (:constitution-file ctx))
(fail! (str red "Error:" reset " Constitution prompt not found at " (:constitution-file ctx))))
(let [roles-dir (:roles-dir ctx)
worktrees-dir (:worktrees-dir ctx)
working-dir (:working-dir ctx)]
(loop [lines (map-indexed vector (str/split-lines (slurp (str (:config-file ctx)))))
rows []
roles #{}
worktrees #{}]
(if-let [[line-index raw-line] (first lines)]
(let [line-no (inc line-index)
line (str/trim raw-line)]
(if (or (str/blank? line) (str/starts-with? line "#"))
(recur (next lines) rows roles worktrees)
(let [fields (str/split line #"\s+")]
(when (< (count fields) 4)
(fail! (str red "Error:" reset " Invalid config line " line-no ": " line)))
(let [[keyword role agent worktree & trailing] fields
agent (str/lower-case agent)
receive-mode (if (#{"task" "batch"} (first trailing))
(first trailing)
"task")
extra-arg-tokens (if (#{"task" "batch"} (first trailing))
(rest trailing)
trailing)
extra-args (when (seq extra-arg-tokens)
(str/join " " extra-arg-tokens))]
(when-not (= "window" keyword)
(fail! (str red "Error:" reset " Unknown config directive on line " line-no ": " keyword)))
(when (str/includes? role "_")
(fail! (str red "Error:" reset " Invalid role '" role "' on line " line-no ": role names may not contain underscores")))
(when (contains? roles role)
(fail! (str red "Error:" reset " Duplicate role '" role "' in " (:config-file ctx))))
(when (and (not (#{"none" "master"} worktree)) (contains? worktrees worktree))
(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" "pi"} 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")))
(when-not (fs/exists? (fs/path roles-dir (str role ".prompt")))
(fail! (str red "Error:" reset " Missing role prompt " (fs/path roles-dir (str role ".prompt")))))
(let [worktree-path (if (#{"none" "master"} worktree)
working-dir
(worktree-path-for-name worktrees-dir worktree))
row {:role role
:agent agent
:session (session-name-for-role role)
:display-name (display-name-for-role role)
:worktree-name worktree
:worktree-path worktree-path
:receive-mode receive-mode
:extra-args extra-args}]
(recur (next lines)
(conj rows row)
(conj roles role)
(cond-> worktrees (not (#{"none" "master"} worktree)) (conj worktree))))))))
(do
(when (empty? rows)
(fail! (str red "Error:" reset " No windows defined in " (:config-file ctx))))
(assoc ctx :roles rows))))))
(defn write-sessions-file! [ctx]
(spit (str (:sessions-file ctx))
(apply str
(map-indexed
(fn [index row]
(format "%d\t%s\t%s\t%s\t%s\n"
(inc index) (:role row) (:session row) (:display-name row) (:agent row)))
(:roles ctx)))))
(defn write-roles-file! [ctx]
(spit (str (:roles-file ctx))
(apply str
(for [row (:roles ctx)]
(format "%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
(:role row)
(:worktree-name row)
(:worktree-path row)
(:session row)
(:display-name row)
(:agent row)
(:receive-mode row))))))
(def required-helpers
["handoff_lib.bb" "swarm_handoff.sh" "swarm_handoff.bb"
"ready_for_next.sh" "ready_for_next.bb"
"done_with_current.sh" "done_with_current.bb"
"ready_for_next_task.sh" "ready_for_next_task.bb"
"done_with_current_task.sh" "done_with_current_task.bb"
"ready_for_next_batch.sh" "ready_for_next_batch.bb"
"done_with_current_batch.sh" "done_with_current_batch.bb"
"handoffd.bb" "stop_handoff_daemon.bb" "stop_handoff_daemon.sh"
"swarm-cleanup.sh" "swarm-window-watchdog.sh" "swarm-window-watchdog.bb"
"swarm-terminal-adapter.sh" "swarmforge.sh" "swarmforge.bb"])
(def terminal-helpers
["terminal-app.sh" "iterm2.sh" "ghostty.sh" "windows-terminal.sh" "none.sh"])
(defn check-helper-scripts! [ctx]
(doseq [helper required-helpers]
(let [path (fs/path (:script-dir ctx) helper)]
(when-not (and (fs/exists? path) (fs/executable? path))
(fail! (str red "Error:" reset " Required helper script not found or not executable: " path)))))
(doseq [helper terminal-helpers]
(let [path (fs/path (:script-dir ctx) "terminal-adapters" helper)]
(when-not (and (fs/exists? path) (fs/executable? path))
(fail! (str red "Error:" reset " Required terminal adapter not found or not executable: " path))))))
(defn prepare-workspace! [ctx]
(doseq [dir [(:state-dir ctx) (:notify-dir ctx) (:prompts-dir ctx)
(:worktrees-dir ctx) (:tmux-socket-dir ctx) (:daemon-dir ctx)]]
(fs/create-dirs dir))
(spit (str (:tmux-socket-file ctx)) (str (:tmux-socket ctx) "\n"))
(check-helper-scripts! ctx)
(write-sessions-file! ctx)
(write-roles-file! ctx))
(defn prepare-worktrees! [ctx]
(doseq [row (:roles ctx)
:let [worktree-name (:worktree-name row)
worktree-path (:worktree-path row)
branch-name (str "swarmforge-" worktree-name)]
:when (not (#{"none" "master"} worktree-name))]
(when-not (or (fs/exists? (fs/path worktree-path ".git"))
(fs/directory? (fs/path worktree-path ".git")))
(when-not (sh-ok? "git" "-C" (str (:working-dir ctx))
"worktree" "add" "--force" "-B" branch-name (str worktree-path) "HEAD")
(fail! (str red "Error:" reset " Failed to create worktree '" worktree-name "'"))))))
(defn prepare-handoff-dirs! [ctx]
(doseq [row (:roles ctx)
dir ["outbox/tmp" "sent" "failed" "inbox/new" "inbox/in_process" "inbox/completed"]]
(fs/create-dirs (fs/path (:worktree-path row) ".swarmforge" "handoffs" dir))))
(defn write-tmux-env-file! [ctx]
(spit (str (:tmux-env-file ctx))
(str (sh-out "tmux" "-S" (:tmux-socket ctx) "display-message" "-p" "#{socket_path},#{pid},#{pane_id}") "\n")))
(defn sync-worktree-scripts! [ctx]
(doseq [row (:roles ctx)
:let [worktree-path (:worktree-path row)]
:when (not= (str worktree-path) (str (:working-dir ctx)))]
(let [role-scripts-dir (fs/path worktree-path "swarmforge" "scripts")
role-state-dir (fs/path worktree-path ".swarmforge")]
(fs/create-dirs role-scripts-dir)
(doseq [entry (fs/list-dir (:script-dir ctx))]
(let [target (fs/path role-scripts-dir (fs/file-name entry))]
(if (fs/directory? entry)
(fs/copy-tree entry target {:replace-existing true})
(fs/copy entry target {:replace-existing true}))))
(fs/create-dirs (fs/path role-state-dir "notify"))
(fs/copy (:sessions-file ctx) (fs/path role-state-dir "sessions.tsv") {:replace-existing true})
(fs/copy (:roles-file ctx) (fs/path role-state-dir "roles.tsv") {:replace-existing true})
(fs/copy (:tmux-socket-file ctx) (fs/path role-state-dir "tmux-socket") {:replace-existing true})
(fs/copy (:tmux-env-file ctx) (fs/path role-state-dir "tmux-env") {:replace-existing true}))))
(defn check-dependency! [command]
(when-not (command-exists? command)
(fail! (str red "Error:" reset " '" command "' is required but not installed."))))
(defn check-backend-dependencies! [ctx]
(doseq [agent (map :agent (:roles ctx))]
(check-dependency! agent)))
(defn create-role-session! [ctx session title]
(sh "tmux" "-S" (:tmux-socket ctx) "new-session" "-d" "-s" session "-n" agent-window)
(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 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")))
(defn extra-args-prefix [row]
(let [args (:extra-args row)]
(if (str/blank? args) "" (str args " "))))
(defn grok-wants-auto-approve? [row]
(when-let [args (:extra-args row)]
(or (str/includes? args "--always-approve")
(str/includes? args "--yolo")
(re-find #"--permission-mode\s+bypassPermissions" args))))
(defn grok-permission-prefix [row]
;; acceptEdits only auto-approves file edits; bypassPermissions is the
;; CLI-enforced mode that matches --always-approve / --yolo.
(if (grok-wants-auto-approve? row)
"--permission-mode bypassPermissions "
"--permission-mode acceptEdits "))
(defn launch-command [ctx index row]
(let [role (:role row)
agent (:agent row)
display (:display-name row)
role-worktree (:worktree-path row)
role-script-dir (if (= (str role-worktree) (str (:working-dir ctx)))
(:script-dir ctx)
(fs/path role-worktree "swarmforge" "scripts"))
prompt-file (fs/path (:prompts-dir ctx) (str role ".md"))
base (str "export SWARMFORGE_ROLE=" (sq role)
" && export PATH=" (sq (str role-script-dir)) ":$PATH"
" && cd " (sq (str role-worktree))
" && ")]
(write-agent-instruction-file! role prompt-file)
(cond-> (str base
(case agent
"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)) ")\"")
"pi" (str "pi -a --name " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "\"$(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")))
" " (sq (:tmux-socket ctx))
" " (sq (str (:window-ids-file ctx)))
(apply str (map #(str " " (sq (:session %))) (:roles ctx)))
" >/dev/null 2>&1 & disown; exit $exit_code"))))
(defn launch-role! [ctx index row]
(let [session (:session row)
display (:display-name row)
prompt-file (fs/path (:prompts-dir ctx) (str (:role row) ".md"))
command (launch-command ctx index row)]
(sh "tmux" "-S" (:tmux-socket ctx) "send-keys" "-t"
(tmux-agent-target display (:tmux-pane-base-index ctx) session)
command "Enter")
(println (str " " cyan "[" display "]" reset " started in session " session))))
(defn stop-handoff-daemon! [ctx]
(process/sh {:continue true}
"bb" (str (fs/path (:script-dir ctx) "stop_handoff_daemon.bb"))
(str (:working-dir ctx))))
(defn uname []
(str/trim (:out (process/sh {:continue true} "uname" "-s"))))
(defn linux-systemd-running? []
(let [result (process/sh {:continue true} "systemctl" "is-system-running")
state (str/trim (:out result))]
(#{"running" "degraded"} state)))
(defn sleep-inhibitor-prefix []
(when-not (= "0" (System/getenv "SWARMFORGE_PREVENT_SLEEP"))
(case (uname)
"Darwin" (when (command-exists? "caffeinate")
["caffeinate" "-dims"])
"Linux" (when (and (command-exists? "systemd-inhibit")
(command-exists? "systemctl")
(linux-systemd-running?))
["systemd-inhibit"
"--what=sleep:idle"
"--who=SwarmForge"
"--why=SwarmForge swarm is active"])
nil)))
(defn start-handoff-daemon! [ctx]
(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})
(println (str green "Started handoff daemon"
(when (> (count command) 2) " with OS sleep prevention")
"."
reset))))
(defn adapter-script [ctx command & args]
(let [script (str "SCRIPT_DIR=" (sq (str (:script-dir ctx))) "\n"
"WORKING_DIR=" (sq (str (:working-dir ctx))) "\n"
"TMUX_SOCKET=" (sq (:tmux-socket ctx)) "\n"
"source " (sq (str (fs/path (:script-dir ctx) "swarm-terminal-adapter.sh")))
" && load_terminal_backend " (sq (:terminal-backend ctx))
" && " command
(apply str (map #(str " " (sq %)) args)))]
["bash" "-c" script]))
(defn terminal-call [ctx command & args]
(apply process/sh (apply adapter-script ctx command args)))
(defn terminal-call-ok? [ctx command & args]
(zero? (:exit (apply process/sh (concat [{:continue true}] (apply adapter-script ctx command args))))))
(defn terminal-call-out [ctx command & args]
(str/trim (:out (apply terminal-call ctx command args))))
(defn open-terminal-surfaces! [ctx]
(if (terminal-call-ok? ctx "terminal_backend_can_open_sessions")
(do
(println (str "Opening separate " (terminal-call-out ctx "terminal_backend_label") " surfaces for each session..."))
(when (terminal-call-ok? ctx "terminal_backend_tracks_windows")
(spit (str (:window-ids-file ctx)) "")
(spit (str (:window-state-file ctx)) ""))
(loop [rows (:roles ctx)
index 0
previous-window-id ""]
(when-let [row (first rows)]
(let [window-id (terminal-call-out ctx "terminal_open_session" (:session row) (str "SwarmForge " (:display-name row)) previous-window-id)]
(if (terminal-call-ok? ctx "terminal_backend_tracks_windows")
(do
(spit (str (:window-ids-file ctx)) (str window-id "\n") :append true)
(spit (str (:window-state-file ctx))
(format "%d\t%s\t%s\t%s\n" (inc index) window-id (:session row) (str "SwarmForge " (:display-name row)))
:append true)
(recur (next rows) (inc index) window-id))
(recur (next rows) (inc index) previous-window-id)))))
(if (terminal-call-ok? ctx "terminal_backend_tracks_windows")
(process/process [(str (fs/path (:script-dir ctx) "swarm-window-watchdog.sh"))
(str (:window-state-file ctx))
(str (:window-ids-file ctx))
"1"
(:tmux-socket ctx)
(str (:working-dir ctx))
(:terminal-backend ctx)]
{:out (str (:window-watchdog-log ctx))
:err :out})
(println (str yellow (terminal-call-out ctx "terminal_backend_label") " surfaces are not trackable; window watchdog is disabled for this backend." reset))))
(do
(println (str yellow "No terminal backend found; attaching current shell to '" (-> ctx :roles first :session) "' instead." reset))
(sh "tmux" "-S" (:tmux-socket ctx) "attach-session" "-t" (-> ctx :roles first :session)))))
(defn context [working-dir]
(let [working-dir (fs/absolutize (fs/path working-dir))
script-dir (fs/parent *file*)
swarm-forge-dir (fs/path working-dir "swarmforge")
state-dir (fs/path working-dir ".swarmforge")
daemon-dir (fs/path state-dir "daemon")
crc (java.util.zip.CRC32.)
_ (.update crc (.getBytes (str working-dir) java.nio.charset.StandardCharsets/UTF_8))
socket-id (str (.getValue crc))
tmux-socket-dir (fs/path "/tmp" (str "swarmforge-" (or (System/getenv "UID") (System/getProperty "user.name"))))
tmux-socket (str (fs/path tmux-socket-dir (str socket-id ".sock")))]
{:working-dir working-dir
: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")
:roles-dir (fs/path swarm-forge-dir "roles")
:constitution-file (fs/path swarm-forge-dir "constitution.prompt")
:state-dir state-dir
:notify-dir (fs/path state-dir "notify")
:window-ids-file (fs/path state-dir "window-ids")
:window-state-file (fs/path state-dir "windows.tsv")
:window-watchdog-log (fs/path state-dir "window-watchdog.log")
:sessions-file (fs/path state-dir "sessions.tsv")
:roles-file (fs/path state-dir "roles.tsv")
:prompts-dir (fs/path state-dir "prompts")
:daemon-dir daemon-dir
:handoff-daemon-log (fs/path daemon-dir "handoffd.log")
:tmux-socket-dir tmux-socket-dir
:tmux-socket tmux-socket
:tmux-socket-file (fs/path state-dir "tmux-socket")
:tmux-env-file (fs/path state-dir "tmux-env")
:tmux-window-base-index 0
:tmux-pane-base-index 0}))
(defn prepare-ctx [ctx]
(-> ctx
parse-config
(assoc :terminal-backend (detect-terminal-backend))))
(defn test-parse! [root]
(let [ctx (prepare-ctx (context root))]
(prepare-workspace! ctx)
(doseq [row (:roles ctx)]
(println (str (:role row) " " (:display-name row) " " (:worktree-path row) " "
(:receive-mode row)
(when-let [extra (:extra-args row)] (str " " extra)))))
(print (slurp (str (:roles-file ctx))))
(print (slurp (str (:sessions-file ctx))))))
(defn run-main! [root]
(check-dependency! "tmux")
(check-dependency! "git")
(check-dependency! "bb")
(let [ctx (-> (context root)
detect-tmux-base-indexes)]
(initialize-git-repo! ctx)
(ensure-runtime-git-excludes! ctx)
(let [ctx (prepare-ctx ctx)]
(check-backend-dependencies! ctx)
(prepare-workspace! ctx)
(prepare-worktrees! ctx)
(prepare-handoff-dirs! ctx)
(let [ctx (assoc ctx :terminal-backend (detect-terminal-backend))]
(stop-handoff-daemon! ctx)
(doseq [row (:roles ctx)]
(when (sh-ok? "tmux" "-S" (:tmux-socket ctx) "has-session" "-t" (:session row))
(println (str yellow "Existing SwarmForge session found: " (:session row) ". Killing it..." reset))
(sh "tmux" "-S" (:tmux-socket ctx) "kill-session" "-t" (:session row))))
(println (str cyan bold))
(println " SwarmForge v1.0 Starting")
(println " Disciplined agents build better software")
(println reset)
(println (str green "Launching SwarmForge tmux sessions..." reset))
(doseq [row (:roles ctx)]
(create-role-session! ctx (:session row) (:display-name row)))
(write-tmux-env-file! ctx)
(sync-worktree-scripts! ctx)
(start-handoff-daemon! ctx)
(println (str green "Starting agents..." reset))
(let [delay-ms (env-long "SWARMFORGE_AGENT_START_DELAY_MS" 1500)]
(doseq [[index row] (map-indexed vector (:roles ctx))]
(when (pos? index)
(Thread/sleep delay-ms))
(launch-role! ctx index row)))
(println)
(println (str green bold "SwarmForge is ready." reset))
(println "Working directory:" (str (:working-dir ctx)))
(println "Sessions:")
(doseq [row (:roles ctx)]
(println (str " " (:display-name row) ": " (:session row))))
(println)
(println (str green "Tip: Write a handoff draft and run swarm_handoff.sh while the swarm is running." reset))
(println (str green "Tip: Reattach manually with 'tmux -S " (:tmux-socket ctx) " attach-session -t <session-name>' if needed." reset))
(println)
(open-terminal-surfaces! ctx)))))
(defn test-terminal-bridge! [root backend]
(let [local-script-dir (fs/path root "swarmforge" "scripts")
ctx (cond-> (assoc (context root) :terminal-backend backend)
(fs/exists? local-script-dir) (assoc :script-dir local-script-dir))]
(println (terminal-call-out ctx "terminal_open_session" "swarmforge-specifier" "SwarmForge Specifier" ""))))
(defn test-tmux-base-indexes! [tmux-socket]
(let [ctx (detect-tmux-base-indexes {:tmux-socket tmux-socket
:tmux-socket-dir (str (fs/parent (fs/path tmux-socket)))})]
(println (:tmux-window-base-index ctx) (:tmux-pane-base-index ctx))))
(defn test-launch-command! [root agent & [extra-args]]
(let [ctx (assoc (context root) :terminal-backend "none")
row {:role "coder"
:agent agent
:session "swarmforge-coder"
:display-name "Coder"
:worktree-name "master"
:worktree-path (fs/path root)
:receive-mode "task"
:extra-args extra-args}]
(fs/create-dirs (:prompts-dir ctx))
(println (launch-command ctx 1 row))))
(defn test-sleep-inhibitor-prefix! []
(println (str/join " " (or (sleep-inhibitor-prefix) []))))
(defn -main [& args]
(case (first args)
"--test-parse" (test-parse! (or (second args) (System/getProperty "user.dir")))
"--test-terminal-bridge" (test-terminal-bridge! (or (second args) (System/getProperty "user.dir")) (nth args 2))
"--test-launch-command" (apply test-launch-command!
(or (second args) (System/getProperty "user.dir"))
(drop 2 args))
"--test-agent-start-delay" (println (env-long "SWARMFORGE_AGENT_START_DELAY_MS" 1500))
"--test-sleep-inhibitor-prefix" (test-sleep-inhibitor-prefix!)
"--test-tmux-base-indexes" (test-tmux-base-indexes! (second args))
(run-main! (or (first args) (System/getProperty "user.dir")))))
(apply -main *command-line-args*)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bb "$SCRIPT_DIR/swarmforge.bb" "$@"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
terminal_backend_label() {
echo "Ghostty"
}
terminal_backend_can_open_sessions() {
return 0
}
terminal_backend_tracks_windows() {
return 0
}
terminal_window_exists() {
local window_id="$1"
[[ -n "$window_id" ]] || return 1
local result
result="$(osascript - "$window_id" <<'APPLESCRIPT' 2>/dev/null || true
on run argv
set targetId to item 1 of argv
tell application "Ghostty"
repeat with w in windows
repeat with t in tabs of w
if (id of t as string) is targetId then return "yes"
end repeat
end repeat
end tell
return "no"
end run
APPLESCRIPT
)"
[[ "$result" == "yes" ]]
}
terminal_open_session() {
local session="$1"
local title="$2"
local sibling_id="${3:-}"
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" "$sibling_id" <<'APPLESCRIPT'
on run argv
set workingDir to item 1 of argv
set tmuxSession to item 2 of argv
set tmuxSocket to item 4 of argv
set siblingTabId to item 5 of argv
set initialCmd to "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession & linefeed
tell application "Ghostty"
set cfg to new surface configuration
set initial working directory of cfg to workingDir
set initial input of cfg to initialCmd
if siblingTabId is not "" then
set targetWin to missing value
set siblingTab to missing value
repeat with w in windows
repeat with t in tabs of w
if (id of t as string) is siblingTabId then
set targetWin to w
set siblingTab to t
exit repeat
end if
end repeat
if targetWin is not missing value then exit repeat
end repeat
if targetWin is not missing value then
select tab siblingTab
set newTab to new tab in targetWin with configuration cfg
return id of newTab
end if
end if
try
set targetWin to front window
set newTab to new tab in targetWin with configuration cfg
return id of newTab
end try
set newWin to new window with configuration cfg
return id of (first tab of newWin)
end tell
end run
APPLESCRIPT
}
terminal_close_window() {
local window_id="$1"
[[ -n "$window_id" ]] || return 0
osascript - "$window_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
on run argv
set targetId to item 1 of argv
tell application "Ghostty"
try
repeat with w in windows
repeat with t in tabs of w
if (id of t as string) is targetId then
close tab t
return
end if
end repeat
end repeat
end try
end tell
end run
APPLESCRIPT
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
terminal_backend_label() {
echo "iTerm2"
}
terminal_backend_can_open_sessions() {
return 0
}
terminal_backend_tracks_windows() {
return 0
}
terminal_window_exists() {
local session_id="$1"
[[ -n "$session_id" ]] || return 1
local result
result="$(osascript - "$session_id" <<'APPLESCRIPT' 2>/dev/null || true
on run argv
set targetId to item 1 of argv
tell application id "com.googlecode.iterm2"
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
if (id of s) is targetId then return "yes"
end repeat
end repeat
end repeat
end tell
return "no"
end run
APPLESCRIPT
)"
[[ "$result" == "yes" ]]
}
terminal_open_session() {
local session="$1"
local title="$2"
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" <<'APPLESCRIPT'
on run argv
set workingDir to item 1 of argv
set tmuxSession to item 2 of argv
set windowTitle to item 3 of argv
set tmuxSocket to item 4 of argv
set attachCmd to "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession
tell application id "com.googlecode.iterm2"
activate
set newWindow to (create window with default profile)
set newSession to current session of newWindow
tell newSession to write text attachCmd
try
set name of newSession to windowTitle
end try
return id of newSession
end tell
end run
APPLESCRIPT
}
terminal_close_window() {
local session_id="$1"
[[ -n "$session_id" ]] || return 0
osascript - "$session_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
on run argv
set targetId to item 1 of argv
tell application id "com.googlecode.iterm2"
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
if (id of s) is targetId then
close w
return
end if
end repeat
end repeat
end repeat
end tell
end run
APPLESCRIPT
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
terminal_backend_label() {
echo "current shell"
}
terminal_backend_can_open_sessions() {
return 1
}
terminal_backend_tracks_windows() {
return 1
}
terminal_window_exists() {
return 1
}
terminal_open_session() {
return 1
}
terminal_close_window() {
return 0
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
terminal_backend_label() {
echo "Terminal"
}
terminal_backend_can_open_sessions() {
return 0
}
terminal_backend_tracks_windows() {
return 0
}
terminal_window_exists() {
local window_id="$1"
[[ -n "$window_id" ]] || return 1
local result
result="$(osascript - "$window_id" <<'APPLESCRIPT' 2>/dev/null || true
on run argv
set targetId to item 1 of argv as integer
tell application "Terminal"
repeat with terminalWindow in windows
if id of terminalWindow is targetId then return "yes"
end repeat
end tell
return "no"
end run
APPLESCRIPT
)"
[[ "$result" == "yes" ]]
}
terminal_open_session() {
local session="$1"
local title="$2"
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" <<'APPLESCRIPT'
on run argv
set workingDir to item 1 of argv
set tmuxSession to item 2 of argv
set windowTitle to item 3 of argv
set tmuxSocket to item 4 of argv
tell application "Terminal"
activate
set newTab to do script ""
do script "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession in newTab
set custom title of newTab to windowTitle
return id of front window
end tell
end run
APPLESCRIPT
}
terminal_close_window() {
local window_id="$1"
[[ -n "$window_id" ]] || return 0
osascript - "$window_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
on run argv
set targetId to item 1 of argv as integer
tell application "Terminal"
try
close (first window whose id is targetId) saving no
end try
end tell
end run
APPLESCRIPT
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
terminal_backend_label() {
echo "Windows Terminal"
}
terminal_backend_can_open_sessions() {
return 0
}
terminal_backend_tracks_windows() {
return 1
}
terminal_window_exists() {
return 1
}
terminal_open_session() {
local session="$1"
local title="$2"
local escaped_working_dir
local escaped_tmux_socket
local escaped_session
escaped_working_dir="$(printf '%q' "$WORKING_DIR")"
escaped_tmux_socket="$(printf '%q' "$TMUX_SOCKET")"
escaped_session="$(printf '%q' "$session")"
wt.exe -w new --title "$title" wsl.exe -e bash -lc \
"cd $escaped_working_dir && exec tmux -S $escaped_tmux_socket attach-session -t $escaped_session"
}
terminal_close_window() {
return 0
}