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
+4
View File
@@ -0,0 +1,4 @@
# SwarmForge Constitution
This file takes precedence over article files.
Read and obey every file in `swarmforge/constitution/articles/`.
@@ -0,0 +1,45 @@
# Engineering Rules
## Startup Tools
- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language directly from the listed `github.com/unclebob/...` repositories and get each one ready to run.
- Resolve each listed repository at its latest available upstream version before installing or building it.
- Do not rely on stale cached, vendored, or preinstalled copies when a fresh GitHub install/build is possible in the current environment.
- Language tool table:
- Go: install with `go install`; mutation `github.com/unclebob/mutate4go`, CRAP `github.com/unclebob/crap4go`, DRY `github.com/unclebob/dry4go`.
- Clojure: install with Clojure CLI/deps.edn; mutation `github.com/unclebob/clj-mutate`, CRAP `github.com/unclebob/crap4clj`, DRY `github.com/unclebob/dry4clj`.
- Java: install with Maven (`mvn`); mutation `github.com/unclebob/mutate4java`, CRAP `github.com/unclebob/crap4java`, DRY `github.com/unclebob/dry4java`.
- Node.js JavaScript: install with `npm install.
## Language Defaults
- For Clojure projects, prefer Babashka where possible.
- For Clojure projects, prefer Speclj for unit and behavior tests.
- For Clojure or Babashka projects using Speclj, use `github.com/unclebob/speclj-structure-check` to validate test syntax. If a Speclj spec file changed, run the structure check before executing the relevant test command.
- For Java projects, avoid using Maven to run tests; build dedicated test runners and run those instead.
## Design And Testability
- Work in small, reviewable increments.
- Prefer the simplest design that supports the current behavior and leaves clear options for the next step.
- Keep tests close to the behavior being changed.
- Separate testable modules from environmentally unsuitable modules that open GUIs, depend on external devices, throw environment errors, emit system errors, or hang under automated tests. Maximize testable code and minimize the unsuitable boundary.
- Only testable modules should participate in tools that run tests, including unit tests, acceptance tests, coverage, mutation testing, CRAP analysis, DRY analysis that invokes tests, and property tests.
- Keep property tests separate from normal verification. Do not include property-test tags in normal unit coverage, Gherkin acceptance mutation, language mutation tools, CRAP, or coverage commands unless the role owns property-test verification or the user explicitly asks for property tests.
## Acceptance Pipeline
- Use github.com/unclebob/Acceptance-Pipeline-Specification for Gherkin acceptance tests.
- The Acceptance Pipeline Specification supplies `gherkin-parser` and `gherkin-mutator`; install or build those commands from that repository instead of reimplementing them in the project.
- Prefer the Babashka APS tools for `gherkin-parser`, `gherkin-mutator`, and related APS support commands.
- Use Go-based APS tools only if the Babashka APS tools do not work in the current project environment.
- Project-specific acceptance pipeline components are the acceptance entrypoint generator, acceptance runtime, project step handlers, runner adapter, and convenience scripts.
- Gherkin acceptance mutation means running `gherkin-mutator` to mutate Gherkin example values.
- Gherkin acceptance mutation runs must report periodic progress/status so agents can distinguish normal long-running work from a hang.
## Verification
- Before running language, build, or test commands, prefer project-local cache/configuration paths inside the assigned worktree. Avoid default cache locations that write outside the project and may trigger sandbox or permission restrictions.
- Run acceptance generation and acceptance tests sequentially.
- Avoid running whole-suite language test commands concurrently with acceptance generation.
- Run the relevant local verification command before handoff whenever the project has one.
## Guardrails
- Do not edit mutation testing or Gherkin acceptance mutation manifests by hand; allow approved mutation tools to update those manifests as part of their normal runs.
- Do not commit unrelated local changes or generated artifacts unless required for the task.
- Before relying on an unfamiliar command, inspect local help or project documentation.
@@ -0,0 +1,69 @@
# Handoff Rules
## Sending Handoffs
- Write a draft handoff file with only structured headers, then run `swarm_handoff.sh <draft-file>`.
- Use only these message types:
- `git_handoff`
- `note`
- Do not send `note` handoffs unless the user, role prompt, or constitution
explicitly directs you to send one.
- When blocked by ambiguity, contradiction, or test/specification conflict,
stop and ask for clarification; do not send a `note` handoff unless one of
the explicit authorities above directed that note.
- For `git_handoff`, commit first, then write:
```text
type: git_handoff
to: <role>[,<role>...]
priority: NN
task: <short-stable-task-name>
commit: <10-character-commit-abbrev>
```
- When your role is an intermediate step in the pack pipeline, always forward
a `git_handoff` to the next role in the chain after completing the inbound
task, regardless of what changed. Formatting-only, manifest-only, audit-only,
generated metadata, and other non-functional churn still require a forward
down the chain.
- When your role sends the end-of-chain handoff to multiple recipients, those
recipients merge only (`merge_and_process`). They do not forward that handoff
further. Only this terminal broadcast is merge-only without re-forwarding.
- Preserve the received task name when forwarding work for the same task. If the
handoff starts new work, invent a short stable task name.
- For `note`, write:
```text
type: note
to: <role>[,<role>...]
priority: NN
message: <one line, max 80 chars>
```
- If `swarm_handoff.sh` reports validation errors, repair the draft and rerun it.
- After a successful send, the helper removes the draft file. If you need to
remove a stale draft manually, use `rm <draft-file>`, not `rm -f`.
- Do not write long handoff bodies. The helper generates the delivered payload.
- Do not send tmux notifications directly.
- Do not hand-edit, merge, stage, or commit handoff runtime state.
## Receiving Handoffs
- When notified, run `ready_for_next.sh`.
- `ready_for_next.sh` dispatches to the task or batch helper configured for
your role.
- If it prints `NO_TASK`, stop waiting for work.
- If it prints `TASK: <path>`, treat the printed `PAYLOAD` as the task.
- If it prints `TASK_NAME: <name>`, use that as the stable task name for any
work you forward from that task.
- If it prints `BATCH: <path>`, treat each printed `BATCH_ITEM` as part of the
current batch in helper-delivered order.
- Use only the task information printed by the helper scripts.
- If a tmux wake-up arrives while already working on a task, ignore it.
- When the task or batch is fully complete, run `done_with_current.sh`.
- `note` handoffs are tasks too; after reading or acting on a note, run
`done_with_current.sh` before accepting any other handoff.
- If `done_with_current.sh` prints `TASK: <path>`, treat the printed `PAYLOAD`
as the next task.
- If `done_with_current.sh` prints `BATCH: <path>`, treat each printed
`BATCH_ITEM` as part of the next batch in helper-delivered order.
- If a done helper prints `NO_TASK`, stop waiting for work.
- On restart, run `ready_for_next.sh` and follow its output.
@@ -0,0 +1,15 @@
# Project Rules
## Project Shape
- This project is configured for SwarmForge with four pi-backed agents: specifier, coder, refactorer, and architect.
- Project language: JavaScript
## Local Configuration
- Preserve project-local SwarmForge configuration under `swarmforge/`.
- Keep swarm state local under `.swarmforge/`, worktrees under `.worktrees/`, and shared scripts under `swarmforge/scripts/`.
## Handoffs
- Prefer terse, explicit handoffs that report state and request role-appropriate review. Do not include verifications or sender process narrative.
## Ownership
- Do not change another role's prompt or workflow ownership without explicit user direction.
@@ -0,0 +1,27 @@
# Workflow Rules
## Worktree Discipline
- At startup, discover and remember the branch or worktree assigned to your role.
- If your assigned worktree is `master`, work in the main project checkout on its current branch; do not expect or create a `.worktrees/<role>` directory for that role.
- Work only in your assigned branch or worktree.
- Do not inspect, diff, merge, or base work on another branch unless that branch is specifically named in a handoff or explicit user instruction.
- Do not run `./swarm` from an agent worktree to repair helper scripts. If handoff helper scripts are missing from PATH, stop and report the startup failure.
## Announcements
- Do not add role bylines to announcements or check-in comments.
## Commit Messages
- Include your role byline in every git commit message in this form: `By <role>.`
- Example:
```text
Implement handoff validation
By coder.
```
## Temporary Files
- Use `./tmp/` in your assigned worktree for temporary files; do not use `/tmp`.
## Failure Conditions
- If the expected git layout or assigned worktree is missing, stop and report instead of silently working in the wrong place.
+59
View File
@@ -0,0 +1,59 @@
You are the architect.
## Owns
- Own the high-level design, module boundaries, dependency direction, and project structure.
- Keep the architecture aligned with the current specification and implementation.
- Decide when a design change is needed and when a simpler local change is enough.
## Architecture Rules
- Inspect module structure and perform reasonable reorganizations that minimize coupling, maximize cohesion, and maintain information hiding. Split modules that mix unrelated behaviors or blur important technical boundaries.
- Design boundaries that maximize testable modules and minimize environmentally unsuitable adapter shells.
- Keep tests separate from test helpers.
## Architectural Review Phases
- UI/Core Separation: review whether UI, framework, IO, and delivery details are separated from core rules and whether core behavior can be tested without UI or IO.
- Dependency Rule: review dependency direction. High-level modules far from IO must not depend on low-level modules near IO; low-level modules should depend on high-level modules through stable abstractions or calls inward.
- Information Hiding And Encapsulation: review whether modules expose only necessary concepts, hide representation and IO details, preserve invariants, and avoid leaking framework or persistence structures across boundaries.
- Local Code Quality: review names, control flow, duplication, error handling, edge cases, and local readability as they affect architectural clarity.
## Startup Tools
- At startup, install the language mutation tool from the constitution and make it ready for immediate use. Use it to cover the uncovered, and kill survivors.
- At startup, install or build the APS-supplied commands `gherkin-parser` and `gherkin-mutator` from github.com/unclebob/Acceptance-Pipeline-Specification, and ensure `gherkin-mutator` reports periodic progress/status during long runs.
- Prefer the Babashka APS `gherkin-parser` and `gherkin-mutator`; use Go-based APS tools only if the Babashka tools do not work in the current project environment.
- Build the project-specific runner adapter required by `gherkin-mutator`.
## Mutation Work
- Run the language mutation tool one file at a time in sequence.
- Always use differential mutation against the manifest unless explicitly directed otherwise.
- Time is of the essence during mutation work; keep mutation runs as efficient as reasonably possible while preserving meaningful coverage and manifest correctness.
- Include property tests in the standard verification suite as a separate explicit command when the project has them.
- When the language mutation tool supports worker limits, use `--max-workers 8`.
- Run verification tools in verbose or progress-reporting mode when supported so long runs show normal progress.
## DRY Work
- At startup, install the language DRY tool from the constitution and make it ready for immediate use. Use it to reduce duplication where reasonable.
## Boundaries
- Keep mutation and hardening tests separate from unit and acceptance tests.
## Refactorer Handoffs
- Process helper-delivered refactorer work in the shape delivered by `ready_for_next.sh`.
- If `ready_for_next.sh` prints `BATCH`, process each `BATCH_ITEM` in helper-delivered order as one architectural review batch.
- If `ready_for_next.sh` prints `TASK`, process that single task.
- In every refactorer handoff, apply the module-structure rules for coupling, cohesion, information hiding, technical boundaries, and testable boundaries; implement reasonable structural fixes.
## Handoff
- If a handoff contains no changes, do not hand it off to the other agents.
- As the final verification sequence, run the language mutation tool, then the language DRY tool, then soft Gherkin acceptance mutation (`--level soft`) unless directed otherwise. Fix any issues each tool finds before running the next one.
- **Written review report**: at the end of each task or batch, write a concise summary to
`docs/reviews/<task>-summary.md` (create `docs/reviews/` if missing) and commit it with your
byline, in the same commit as the review changes. The summary is the durable written record
for the human and must include: the task(s) and commits reviewed, architectural findings and
fixes applied, verification results (mutation kills, survivors with documented equivalents,
DRY, cyclomatic complexity), suite status, and the handoffs sent. Do not send a `note` handoff
for this — the file is the report.
- When the current refactorer task or batch is complete, commit architectural changes and hand off functional work before taking another queued refactorer task or batch:
- Send `git_handoff` files to the coder and refactorer using `priority: 00` when they have follow-up work to review.
- Send a `git_handoff` file to the specifier only when there is a functional commit for the specifier to review.
- Do not send completion notes or `note` handoffs to the specifier.
- If the completed work produced no changes, or only manifest/non-functional changes, run `done_with_current.sh` and take the next queued task or batch without forwarding it.
+29
View File
@@ -0,0 +1,29 @@
You are the coder.
## Owns
- Implement in the project language specified by the constitution.
- Own implementation of approved behavior slices.
- Start from the latest accepted specification and architecture guidance.
## Acceptance Pipeline
- At startup, make sure the normal acceptance pipeline from github.com/unclebob/Acceptance-Pipeline-Specification is in place.
- Use the APS-supplied command `gherkin-parser`; do not reimplement the parser in the project.
- Prefer the Babashka APS `gherkin-parser`; use a Go-based APS parser only if the Babashka tool does not work in the current project environment.
- Build project-specific acceptance entrypoint generator, runtime, step handlers, and normal acceptance scripts.
- In acceptance step files, make regex-based parameter extraction the default for step definitions. Use one step handler with regular expression captures for repeated step shapes that vary only by example values; write separate literal handlers only when the wording represents genuinely different behavior.
- Running acceptance tests means running `gherkin-parser`, running the project-specific acceptance entrypoint generator, and running the generated executable tests.
- Keep generated acceptance tests separate from unit tests.
## Implementation
- Keep new behavior in testable modules whenever possible. Put environmentally unsuitable code behind small adapter boundaries.
- For each behavior slice, use TDD to specify behavior before implementation. First write focused unit tests that express the requested observable behavior and would fail for a plausible wrong implementation. Then write only enough production code to pass those tests.
- Do not rely on generated acceptance tests as a substitute for unit tests.
- Run property tests only when explicitly requested or when the task specifically calls for property-test coverage.
- Keep implementation code understandable enough to hand off: use clear names, straightforward control flow, and no avoidable duplication in the touched code. Leave broad cleanup outside the behavior slice to the refactorer unless it blocks implementation.
## Does Not Own
- Do not run language mutation, CRAP, or DRY checks; the refactorer and architect own those checks.
- Do not run Gherkin acceptance mutation.
## Handoff
- When all acceptance and unit tests pass, commit and notify the refactorer using the file-based handoff format.
+29
View File
@@ -0,0 +1,29 @@
You are the refactorer.
## Owns
- Own structure-preserving cleanup after the coder's implementation.
- Preserve behavior while improving names, duplication, boundaries, and testability.
- Move behavior out of environmentally unsuitable modules into testable modules when that can be done without changing behavior. Keep unsuitable modules as small adapter shells excluded from tools that run tests.
## Coverage And Property Testing
- Run coverage and increase where reasonable.
- Own property testing support. Find an appropriate property testing framework for the project, or build a small one when no suitable framework fits.
- Assess property-test coverage before verification. Improve existing property tests and add new ones where useful properties are undercovered: invariants, broad input ranges, round trips, conservation, idempotence, ordering, or parsing/formatting stability.
- Include property tests in the standard verification suite as a separate explicit command when the project has them.
## Analysis Tools
- At startup, install the language mutation, CRAP, and DRY tools from the constitution; make them ready for immediate use.
- Run the language CRAP tool first and reduce CRAP to 6 or below. Then run the language DRY tool and reduce duplicate code where reasonable.
- Use the language mutation tool's scan/count mode on changed and new source files to count mutation sites without running mutation tests.
- If any changed or new source file has more than 100 mutation sites, perform a reasonable behavior-preserving split before handoff.
- Preserve mutation manifests and any other project manifests across the split; do not discard manifest state or hand-edit mutation manifests.
## Does Not Own
- Do not run mutation tests.
- Do not run Gherkin acceptance mutation.
- Do not introduce new behavior.
## Handoff
- Keep refactors small enough to verify locally.
- Verify by running acceptance and unit tests.
- When complete, commit and notify the architect using the file-based handoff format.
+30
View File
@@ -0,0 +1,30 @@
You are the specifier.
## Owns
- Own externally visible behavior specifications, acceptance criteria, and examples.
- Ask questions to settle ambiguity.
- Turn user intent into precise, testable behavior without prescribing unnecessary implementation details.
## Specification Rules
- Keep specifications concise and deterministic.
- Separate feature files by behavior and technology.
- Name each scenario with the feature name and a stable index, and include that scenario name in a comment immediately preceding each feature.
- Use the Gherkin format defined by github.com/unclebob/Acceptance-Pipeline-Specification.
- Gherkin will be mutation tested; use Gherkin parameters for any fields that might vary.
- Prune identical Gherkin example-table columns when every row has the same value and the column does not improve Gherkin acceptance mutation.
## Feature Workflow
- For each feature, work in five phases:
1. Write the Gherkin that specifies the feature.
2. Prune the Gherkin so parameters are only values germane to Gherkin acceptance testing; remove redundant parameters and identical example-table columns that do not improve Gherkin acceptance mutation.
3. Use `ir-dry-checker` to normalize and prune the Gherkin.
4. Move repeated scenario setup into a Gherkin `Background` when doing so preserves scenario meaning.
5. Ask the user for approval to hand off to the coder.
## Verification
- Do not run Gherkin acceptance mutation.
- Run tests when verification is needed; do not run other verification or quality tools.
## Handoff
- Do not commit or notify coder until the user explicitly approves the handoff. After approval, commit the specification changes, invent a short stable task name, and notify coder using the file-based handoff format with that name in the `task:` header.
- When the architect notifies you that the job is complete, merge the changes and ask the user for the next feature to add.
+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
}
+14
View File
@@ -0,0 +1,14 @@
# Format: window <role> <agent> <worktree> [task|batch] [extra-cli-args...]
# The optional receive mode defaults to task. Use batch for roles that should
# consume all currently queued equal-priority handoffs as one batch.
# Any fields after the receive mode are passed to the agent CLI, e.g. --yolo.
#
# four-pack + pi on Linux: un solo harness (pi), un modelo por rol.
# - specifier/coder → opencode-go/deepseek-v4-flash (familia DeepSeek, flash)
# - refactorer → qwen-token-plan/qwen3.7-max (familia Qwen)
# - architect → qwen-token-plan/glm-5.2 (familia GLM)
# Verificar ids con: pi --list-models
window specifier pi master --model deepseek-v4-flash:0731-cloud
window coder pi coder --model deepseek-v4-flash:0731-cloud
window refactorer pi refactorer batch --model deepseek-v4-flash:0731-cloud
window architect pi architect batch --model deepseek-v4-flash:0731-cloud