mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Adding swarm-forge to repo
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.claude/
|
||||||
|
.swarmforge/
|
||||||
|
.worktrees/
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# SwarmForge — pi-optimized, universal agent support
|
||||||
|
|
||||||
|
Fork of [unclebob/swarm-forge](https://github.com/unclebob/swarm-forge) (based on the `four-pack`
|
||||||
|
workflow) that runs the swarm with **[pi](https://pi.dev)** as the agent harness — while keeping
|
||||||
|
**universal support** for other agent CLIs, **any model** per role, and **bash** instead of zsh.
|
||||||
|
|
||||||
|
The `four-pack` pipeline is unchanged: `specifier` → `coder` → `refactorer` → `architect` with
|
||||||
|
human approval at the specifier gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# copy this fork's main branch into a project directory
|
||||||
|
curl -L "https://github.com/pablo-io/swarm-forge/archive/refs/heads/main.tar.gz" | tar -xz --strip-components=1
|
||||||
|
|
||||||
|
# edit swarmforge/swarmforge.conf (pick your models, see below), then start
|
||||||
|
SWARMFORGE_TERMINAL=none ./swarm
|
||||||
|
|
||||||
|
# observe/steer agents (headless Linux: attach to sessions manually)
|
||||||
|
tmux -S "$(cat .swarmforge/tmux-socket)" attach -t swarmforge-coder
|
||||||
|
|
||||||
|
# stop the swarm
|
||||||
|
./close-swarm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using any model (per role)
|
||||||
|
|
||||||
|
Models are **configuration, not code**: the `extra-args` field of `swarmforge.conf` is passed
|
||||||
|
straight to the agent CLI. To pick a different model, change `--model` (pi) or the equivalent
|
||||||
|
flag of your backend:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
# pi + models per role (one provider per model family)
|
||||||
|
window specifier pi master --model opencode-go/deepseek-v4-flash
|
||||||
|
window coder pi coder --model opencode-go/deepseek-v4-flash
|
||||||
|
window refactorer pi refactorer batch --model qwen-token-plan/qwen3.7-max
|
||||||
|
window architect pi architect batch --model qwen-token-plan/glm-5.2
|
||||||
|
|
||||||
|
# or a different backend entirely
|
||||||
|
window coder codex coder --yolo
|
||||||
|
window architect claude architect batch --dangerously-skip-permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
- List what your harness offers: `pi --list-models` / `pi --list-models <provider>` (run
|
||||||
|
`pi update --models` after catalog changes).
|
||||||
|
- pi supports DeepSeek, Qwen (incl. GLM) and many providers natively — no custom provider
|
||||||
|
extensions needed for those.
|
||||||
|
- Anything after the receive mode (`task`/`batch`) is passed to the agent CLI: `--model`,
|
||||||
|
`--thinking`, `--yolo`, etc.
|
||||||
|
|
||||||
|
|
||||||
|
## Monitoring tools
|
||||||
|
|
||||||
|
The `tools/` folder ships two small bash utilities (no dependencies) for operating a swarm:
|
||||||
|
|
||||||
|
- **`tools/sf-queue [project-dir]`** — live handoff queue state for every role: pending (`new`),
|
||||||
|
in-process (incl. batches), completed, outbox, sent, plus task names. Reads `.swarmforge/roles.tsv`
|
||||||
|
and works with any agent backend.
|
||||||
|
- **`tools/sf-tokens [project-dir]`** — token consumption per role (input / output / cache / total)
|
||||||
|
from pi's session files (`~/.pi/agent/sessions`, organized per worktree). Cumulative across
|
||||||
|
restarts. pi-specific (only meaningful when pi is the backend).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tools/sf-queue /path/to/project # queue state per role
|
||||||
|
bash tools/sf-tokens /path/to/project # tokens per role (pi sessions)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding another agent backend
|
||||||
|
|
||||||
|
The launcher validates backends in `parse-config` and builds the launch command in
|
||||||
|
`launch-command` (`swarmforge/scripts/swarmforge.bb`). To add a CLI:
|
||||||
|
|
||||||
|
1. Add the name to the allowed set in `parse-config`:
|
||||||
|
```clojure
|
||||||
|
(when-not (#{"claude" "codex" "copilot" "grok" "pi" "your-agent"} agent)
|
||||||
|
```
|
||||||
|
2. Add a `case` arm in `launch-command` that starts the CLI interactively with the initial
|
||||||
|
prompt, e.g. for pi:
|
||||||
|
```clojure
|
||||||
|
"pi" (str "pi -a --name " (sq (str "SwarmForge " display)) " "
|
||||||
|
(extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")
|
||||||
|
```
|
||||||
|
3. The binary must be on `PATH` (the launcher checks it). The agent must be an **interactive
|
||||||
|
TUI/REPL** that accepts typed input — wake-ups arrive as a typed message + Enter.
|
||||||
|
|
||||||
|
Tip: for many backends, a per-harness launch script (like `terminal-adapters/`) is cleaner than
|
||||||
|
adding `case` arms — the fork is happy to host one per agent.
|
||||||
|
|
||||||
|
## Changes & improvements vs upstream
|
||||||
|
|
||||||
|
| # | Change | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **pi as agent backend** | `parse-config` accepts `pi`; launch arm `pi -a --name 'SwarmForge <Role>' [extra] "$(cat prompt)"`. pi starts interactive, sends the initial prompt, stays in the TUI. |
|
||||||
|
| 2 | **Bash-native, zero zsh dependency** | All shell scripts migrated: shebangs `zsh` → `bash`, `${1:l}` → `tr`, `<->` glob → regex, `&!` → `& disown` (tmux pane shell is bash), `zsh -c` → `bash -c` in `swarmforge.bb`/`swarm-window-watchdog.bb`. Bash is the default on Linux and exists on every platform. |
|
||||||
|
| 3 | **Universal wake-up (pi + others)** | Upstream typed the wake-up text then a separate Enter; pi-tui drops a standalone CR, so messages sat in the editor. The fork embeds the CR in the same write (`text\r`) and keeps the trailing LF for other TUIs — validated in pi, compatible with claude/codex/grok. |
|
||||||
|
| 4 | **Self-contained branch** | `swarmforge/scripts/` vendored (upstream ignores it and downloads from `main`); `.gitignore` adjusted; empty `scripts/shared-articles/` stops the wrapper download. No surprise overwrites from upstream. |
|
||||||
|
| 5 | **Shared constitution articles included** | `engineering.prompt`, `handoffs.prompt`, `workflow.prompt` copied into `constitution/articles/` (upstream `four-pack` only carries `project.prompt`, so agents had no handoff/workflow rules). |
|
||||||
|
| 6 | **Git identity and worktree failure detection** | Upstream fails *silently* when `git commit` cannot run (no `user.name`/`user.email`) → unborn HEAD → empty worktrees. Now: clear error with instructions before `git init`, and `fail!` if `git worktree add` fails. |
|
||||||
|
| 7 | **Written review reports** | The architect commits `docs/reviews/<task>-summary.md` at the end of every task/batch (what was reviewed, fixes, verification results, suite status, handoffs sent) — the review record is durable and versioned instead of living only in the agent's session. |
|
||||||
|
|
||||||
|
### Where review reports are documented, and when
|
||||||
|
|
||||||
|
The architect writes the summary to **`docs/reviews/<task>-summary.md`** (one per task, created
|
||||||
|
under `docs/reviews/` in the repo) **at the end of each task or batch**, committed with its byline
|
||||||
|
in the same commit as the review changes. Because it is a git commit, the report:
|
||||||
|
|
||||||
|
- travels with the branch and arrives on `master` when the specifier merges the architect's work,
|
||||||
|
- is versioned — you can diff `docs/reviews/` across runs to see every review checkpoint
|
||||||
|
(the pipeline "cuts"),
|
||||||
|
- replaces the informal closing message the architect used to leave only in its own TUI.
|
||||||
|
|
||||||
|
Example layout after a run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/reviews/
|
||||||
|
user-login-summary.md
|
||||||
|
login-form-summary.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope (verified)
|
||||||
|
|
||||||
|
Verified on Linux (Arch) with the real pi CLI and real provider credentials:
|
||||||
|
|
||||||
|
- [x] `bb test` — 24 tests, 91 assertions, 0 failures (no zsh required)
|
||||||
|
- [x] Launch command generation: `pi -a --name 'SwarmForge <Role>' --model <provider/model> "$(cat prompt)"`
|
||||||
|
- [x] Swarm startup headless (`SWARMFORGE_TERMINAL=none`): tmux sessions, real git worktrees with
|
||||||
|
`swarmforge-<role>` branches, agents live with the correct model per role
|
||||||
|
- [x] Handoff protocol end-to-end: commit → draft → validation → outbox → daemon → inbox
|
||||||
|
(generated headers + `merge_and_process` payload) → tmux wake-up → `ready_for_next.sh`
|
||||||
|
(task and batch semantics)
|
||||||
|
- [x] Autonomous chain: coder → refactorer → architect with automatic task pickup and forwarding
|
||||||
|
- [x] Clean shutdown via `close-swarm`
|
||||||
|
- [x] Git identity guard: clear failure with instructions when identity is missing
|
||||||
|
- [x] Universal wake-up: auto-submit in pi with embedded CR; trailing LF retained for other TUIs
|
||||||
|
|
||||||
|
Not tested: macOS/Windows terminal adapters (bash-ported from upstream, not exercised on Linux),
|
||||||
|
window watchdog with a trackable terminal backend (Linux headless mode has no windows by design).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **bash** (default shell; tmux panes run it). No zsh required.
|
||||||
|
- Linux: any modern distro (bash ≥ 4.4). Windows/WSL: fine.
|
||||||
|
- macOS: system bash is 3.2 — `close-swarm` uses array append (`sessions+=(...)`, bash ≥ 4);
|
||||||
|
use a modern bash (Homebrew) or zsh on macOS.
|
||||||
|
- `git` with **`user.name` and `user.email` configured** — enforced with a clear error at startup.
|
||||||
|
- `tmux` ≥ 3.5 (recommended for pi extended keys).
|
||||||
|
- Babashka (`bb`) — single static binary.
|
||||||
|
- One or more agent CLIs on `PATH` (pi, codex, claude, …), authenticated for the providers you use.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **This branch is self-contained**: it does not auto-follow upstream `main` script updates
|
||||||
|
(scripts are vendored). Rebase/merge manually if you want upstream changes.
|
||||||
|
- Handoff semantics are unchanged from upstream: only `git_handoff` / `note`, priorities 00–99,
|
||||||
|
chain forwarding always, terminal broadcast merge-only. See `swarmforge/handoff-protocol.md`.
|
||||||
|
- Upstream is zsh-based; if you pull upstream scripts, re-apply the bash migration
|
||||||
|
(2 syntax sites + shebangs + `zsh -c` + `&!`).
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{:paths ["test"]
|
||||||
|
:tasks
|
||||||
|
{test {:doc "Run helper tests"
|
||||||
|
:task (do
|
||||||
|
(require 'clojure.test)
|
||||||
|
(require 'swarmforge.handoff-test)
|
||||||
|
(require 'swarmforge.script-test)
|
||||||
|
(let [{:keys [fail error]} (clojure.test/run-tests 'swarmforge.handoff-test
|
||||||
|
'swarmforge.script-test)]
|
||||||
|
(System/exit (+ fail error))))}}}
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: close-swarm [project-root]" >&2
|
||||||
|
echo "Stops the SwarmForge swarm for the given project (default: current directory)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "${1:-.}" && pwd)"
|
||||||
|
STATE_DIR="$PROJECT_ROOT/.swarmforge"
|
||||||
|
SOCKET_FILE="$STATE_DIR/tmux-socket"
|
||||||
|
SESSIONS_FILE="$STATE_DIR/sessions.tsv"
|
||||||
|
WINDOW_IDS_FILE="$STATE_DIR/window-ids"
|
||||||
|
WINDOWS_STATE_FILE="$STATE_DIR/windows.tsv"
|
||||||
|
|
||||||
|
if [[ ! -d "$STATE_DIR" ]]; then
|
||||||
|
echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -x "$SELF_DIR/swarmforge/scripts/swarm-cleanup.sh" ]]; then
|
||||||
|
SCRIPT_DIR="$SELF_DIR/swarmforge/scripts"
|
||||||
|
elif [[ -x "$SELF_DIR/swarm-cleanup.sh" ]]; then
|
||||||
|
SCRIPT_DIR="$SELF_DIR"
|
||||||
|
elif [[ -x "$PROJECT_ROOT/swarmforge/scripts/swarm-cleanup.sh" ]]; then
|
||||||
|
SCRIPT_DIR="$PROJECT_ROOT/swarmforge/scripts"
|
||||||
|
else
|
||||||
|
echo "Could not find swarm-cleanup.sh relative to $SELF_DIR or $PROJECT_ROOT." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SOCKET_FILE" ]]; then
|
||||||
|
echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/tmux-socket)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMUX_SOCKET="$(tr -d '[:space:]' < "$SOCKET_FILE")"
|
||||||
|
if [[ -z "$TMUX_SOCKET" ]]; then
|
||||||
|
echo "No SwarmForge swarm found at $PROJECT_ROOT (empty tmux socket)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sessions=()
|
||||||
|
if [[ -f "$SESSIONS_FILE" ]]; then
|
||||||
|
while IFS=$'\t' read -r _index _role session _rest; do
|
||||||
|
[[ -n "${session:-}" ]] || continue
|
||||||
|
sessions+=("$session")
|
||||||
|
done < "$SESSIONS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#sessions[@]} == 0 )) && [[ -f "$WINDOWS_STATE_FILE" ]]; then
|
||||||
|
while IFS=$'\t' read -r _index _window_id session _title; do
|
||||||
|
[[ -n "${session:-}" ]] || continue
|
||||||
|
sessions+=("$session")
|
||||||
|
done < "$WINDOWS_STATE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#sessions[@]} == 0 )) && [[ -S "$TMUX_SOCKET" ]]; then
|
||||||
|
while IFS= read -r session; do
|
||||||
|
[[ -n "$session" ]] || continue
|
||||||
|
sessions+=("$session")
|
||||||
|
done < <(tmux -S "$TMUX_SOCKET" list-sessions -F '#{session_name}' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${SWARMFORGE_TERMINAL:-}" && -z "${SWARMFORGE_TERMINAL_BACKEND:-}" ]]; then
|
||||||
|
export SWARMFORGE_TERMINAL_BACKEND="$SWARMFORGE_TERMINAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$SCRIPT_DIR/swarm-cleanup.sh" "$TMUX_SOCKET" "$WINDOW_IDS_FILE" "${sessions[@]+${sessions[@]}}"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Plan: Option B — `SWARMFORGE_DIR` (external machinery, per-project config)
|
||||||
|
|
||||||
|
> **Status**: pending implementation — working document to resume later.
|
||||||
|
> **Context**: came out of the trial run with `saas-prototype` (see the fork README).
|
||||||
|
> The goal is that projects do not carry SwarmForge code, only their configuration.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
The project stops carrying SwarmForge **code** (scripts) and **shared rules**
|
||||||
|
(articles, default roles). It only keeps its **own configuration** (`swarmforge.conf` +
|
||||||
|
`project.prompt` + overrides). The fork (or any shared location via `SWARMFORGE_DIR`)
|
||||||
|
is the sole source of the machinery. With `SWARMFORGE_DIR` unset, **everything still works
|
||||||
|
as today** (full backward compatibility).
|
||||||
|
|
||||||
|
## 2. Design: where each thing lives
|
||||||
|
|
||||||
|
```
|
||||||
|
SWARMFORGE_DIR (base, e.g. ~/.local/share/swarmforge = fork clone)
|
||||||
|
├── swarm/ + swarmforge/scripts/ ← launcher, daemon, helpers, adapters
|
||||||
|
├── swarmforge/roles/*.prompt ← default roles
|
||||||
|
└── swarmforge/constitution/articles/ ← engineering, handoffs, workflow (shared)
|
||||||
|
|
||||||
|
PROJECT
|
||||||
|
└── swarmforge/ ← "thin": ONLY per-project material
|
||||||
|
├── swarmforge.conf ← project roles + models
|
||||||
|
└── constitution/articles/project.prompt (+ local-*.prompt, role overrides if any)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Merge rule**: the project wins by name — if the project has
|
||||||
|
`roles/cleaner.prompt`, that takes precedence; otherwise the base one is used.
|
||||||
|
|
||||||
|
## 3. Code changes (file by file)
|
||||||
|
|
||||||
|
| File | Change | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `swarmforge.bb` → `context` | Add `:base-dir` = `(or (System/getenv "SWARMFORGE_DIR") (fs/path working-dir "swarmforge"))`; keep `:swarm-forge-dir` = `working-dir/swarmforge` (project config) | Shared base vs. per-project config |
|
||||||
|
| `swarmforge.bb` → `parse-config` | `roles-dir` = per-role lookup: `project/roles/<role>.prompt` if it exists, else `base/roles/<role>.prompt` | Role overrides |
|
||||||
|
| `swarmforge.bb` → new `sync-shared-config!` | For each worktree **and for master**: copy from `base` the shared articles and default roles into the destination `swarmforge/` **only if missing** (project ones win); scripts as today | The agent reads `swarmforge/constitution.prompt` relative to its cwd — after sync, the merged view is there |
|
||||||
|
| `swarmforge.bb` → `prepare-workspace!` / setup | When syncing on master (project root), add derived files (shared articles) to `.gitignore` so the project's git stays clean | Shared articles are generated at startup, not committed |
|
||||||
|
| `write-agent-instruction-file!` | **No changes** | Relative paths still work because sync merges the view into each worktree |
|
||||||
|
| `check-helper-scripts!` | **No changes** (validates `script-dir`, which points at the base) | — |
|
||||||
|
| `handoffd.bb`, helpers, adapters | **No changes** | Already resolve the project via git (`roles.tsv`) |
|
||||||
|
| `swarm` wrapper | Small install/docs tweak: when installed globally, it runs the base `swarmforge.sh`; the download block remains for first-time setup | Global install |
|
||||||
|
|
||||||
|
**Estimated total**: ~40-60 new/modified lines in `swarmforge.bb` + docs. Nothing else.
|
||||||
|
|
||||||
|
## 4. User setup (once)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install the machinery once
|
||||||
|
git clone https://github.com/pablo-io/swarm-forge ~/.local/share/swarmforge
|
||||||
|
ln -s ~/.local/share/swarmforge/swarm ~/.local/bin/swarm
|
||||||
|
export SWARMFORGE_DIR=~/.local/share/swarmforge # (in your .bashrc)
|
||||||
|
|
||||||
|
# 2. In any project: create ONLY the config
|
||||||
|
mkdir -p swarmforge/constitution/articles
|
||||||
|
# swarmforge.conf + project.prompt (+ local-* / overrides if applicable)
|
||||||
|
|
||||||
|
# 3. Run
|
||||||
|
cd /my/project && swarm
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Test plan
|
||||||
|
|
||||||
|
1. **`bb test`** — the existing suite (24 tests) must pass with no semantic changes.
|
||||||
|
2. **Mode B**: minimal project with only conf + `project.prompt` → launch with `SWARMFORGE_DIR`
|
||||||
|
→ verify: worktrees with the merged view (shared articles + roles + scripts),
|
||||||
|
functional master, clean startup.
|
||||||
|
3. **Handoff smoke**: a real end-to-end handoff (like the `saas-prototype` run)
|
||||||
|
under mode B.
|
||||||
|
4. **Backward compatibility**: `saas-prototype` (with full `swarmforge/`) without the env var →
|
||||||
|
must keep working the same.
|
||||||
|
5. **Sync**: change a script in the fork (e.g. a fix) → it is reflected without copying
|
||||||
|
anything into the project.
|
||||||
|
|
||||||
|
## 6. Optional migration of `saas-prototype` (after validating B)
|
||||||
|
|
||||||
|
- Thin its `swarmforge/`: delete `scripts/`, `roles/`, and shared articles; leave
|
||||||
|
`swarmforge.conf` + `project.prompt` (with the design rules).
|
||||||
|
- Re-copy updated prompts from the fork (`architect.prompt` with the written-report
|
||||||
|
rule) — now via the base, not manual copy.
|
||||||
|
|
||||||
|
## 7. Risks / open decisions
|
||||||
|
|
||||||
|
- **Shared articles synced onto master** will be gitignored (derived) — if someone wants
|
||||||
|
to version them explicitly, they can commit them (sync does not overwrite existing ones).
|
||||||
|
- **`roles.tsv` and state** stay under the project's `.swarmforge/` (gitignored) — unchanged.
|
||||||
|
- Agent instructions remain relative to the worktree — key to the design
|
||||||
|
(zero protocol changes).
|
||||||
|
|
||||||
|
## 8. Suggested implementation order
|
||||||
|
|
||||||
|
1. `context` + `parse-config` (base dir + role lookup)
|
||||||
|
2. `sync-shared-config!` + gitignore for derived files
|
||||||
|
3. `bb test`
|
||||||
|
4. Mode B trial with a minimal project
|
||||||
|
5. Doc in the fork README ("SWARMFORGE_DIR mode" section)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Proposal: Configurable quality level per project
|
||||||
|
|
||||||
|
> **Status**: proposal pending implementation — working document.
|
||||||
|
> **Origin**: evaluation of the `saas-prototype` run (see the project's `REPORT.md`): fixed
|
||||||
|
> gates (CRAP≤6, mutation run, DRY, soft Gherkin) cost tokens that not every project needs.
|
||||||
|
> This proposal adds a configurable **quality axis** per project.
|
||||||
|
> **Note**: the two-pack review (section 5) narrows the proposal — the real value is depth
|
||||||
|
> within a pack, not replacing pack choice.
|
||||||
|
|
||||||
|
## 1. The 3 levels
|
||||||
|
|
||||||
|
| Level | Relative cost | Typical use |
|
||||||
|
|---|---|---|
|
||||||
|
| `minimal` | ~1x | Prototypes, spikes, throwaway code |
|
||||||
|
| `standard` | ~2x | Reasonable default for product features |
|
||||||
|
| `maximum` | ~3-4x | **Current rigor** — critical libraries, security/payments, code consumed by others |
|
||||||
|
|
||||||
|
`maximum` = what the pipeline already does today (nothing beyond that for now).
|
||||||
|
|
||||||
|
## 2. Gates by level
|
||||||
|
|
||||||
|
| Gate | minimal | standard | maximum (current) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| TDD + unit tests | ✓ | ✓ | ✓ |
|
||||||
|
| Acceptance Gherkin | optional | ✓ (without full APS pipeline) | ✓ full APS pipeline |
|
||||||
|
| CRAP | — | improve what is reasonable, no hard gate | **≤6** |
|
||||||
|
| DRY | — | reduce reasonable duplication | tooling, strict |
|
||||||
|
| Mutation scan + split >100 sites | — | ✓ (count only — cheap) | ✓ |
|
||||||
|
| Full mutation run | — | — | ✓ differential, kill non-equivalents |
|
||||||
|
| Soft Gherkin mutation | — | — | ✓ |
|
||||||
|
| Property tests | — | — | support |
|
||||||
|
| Written report | — | optional | ✓ |
|
||||||
|
|
||||||
|
## 3. Role responsibility by level (four-pack)
|
||||||
|
|
||||||
|
| Role | minimal | standard | maximum |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **specifier** | Scoping + human gate only (no mandatory Gherkin) | Gherkin ✓ | Gherkin + QA suite |
|
||||||
|
| **coder** | Implements with TDD — required | ✓ | ✓ |
|
||||||
|
| **refactorer** | No gates → **no real work** | Reasonable CRAP + DRY + mutation scan | full gates |
|
||||||
|
| **architect** | No gates → **no real work** | light structural review only | full gates |
|
||||||
|
|
||||||
|
**Conclusion**: level and workflow are correlated. At `minimal`, refactorer/architect have no
|
||||||
|
gates to apply — configuring 4 roles would waste tokens with no benefit.
|
||||||
|
|
||||||
|
## 4. Mechanism (prompts/articles only, zero code)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. PROJECT (project.prompt): "## Quality Level → Quality level: standard"
|
||||||
|
2. SHARED (quality.prompt): the ON/OFF gate table by level
|
||||||
|
3. EACH ROLE PROMPT (one line): "Apply only the gates that are ON for
|
||||||
|
the project's level (see quality article)"
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent reads the level in `project.prompt`, the table in `quality.prompt`, and its role
|
||||||
|
prompt tells it to apply only the ON gates → consistent interpretation across roles.
|
||||||
|
|
||||||
|
The `quality.prompt` article also includes the **role mapping by level**: "at minimal,
|
||||||
|
configure only specifier+coder (2 windows in `swarmforge.conf`); at standard, add
|
||||||
|
refactorer; at maximum, all 4".
|
||||||
|
|
||||||
|
**Honest nuance**: the adjustment is *prompt-soft* — agents follow the level by instruction.
|
||||||
|
Hard enforcement would need a `tools/quality-check` (validate level artifacts), an optional
|
||||||
|
later step.
|
||||||
|
|
||||||
|
## 5. Review: does two-pack already solve part of this?
|
||||||
|
|
||||||
|
**Result of reviewing two-pack's real scope (original project):**
|
||||||
|
|
||||||
|
- **coder (two-pack)**: TDD + unit tests ONLY — explicitly excludes acceptance, Gherkin, IR,
|
||||||
|
Gherkin mutation, property tests, CRAP, DRY, and language mutation.
|
||||||
|
- **cleaner (two-pack, batch)**: coverage, **CRAP≤6**, **DRY**, structure/encapsulation/dependencies
|
||||||
|
and **mutation run on uncovered behavior** + tests to kill mutants.
|
||||||
|
|
||||||
|
**two-pack quality profile**: unit tests ✓ · CRAP≤6 ✓ · DRY ✓ · mutation run ✓ ·
|
||||||
|
structure ✓ (inside cleaner) · acceptance/Gherkin ✗ · property ✗ · separate QA ✗.
|
||||||
|
|
||||||
|
### Conclusion
|
||||||
|
|
||||||
|
1. **two-pack is NOT `minimal`**: it keeps the hard hardening gates (CRAP≤6, DRY, mutation
|
||||||
|
run). It is "full hardening without specification" — not the cheap option on the depth
|
||||||
|
axis.
|
||||||
|
2. **Packs already encode a quality axis**: which gates EXIST (two-pack: no spec;
|
||||||
|
four-pack: spec + architecture; six-pack: + hardender + QA).
|
||||||
|
3. **The level axis adds what packs do NOT cover**: the DEPTH of each active gate
|
||||||
|
(CRAP≤6 vs "improve reasonably"; mutation run vs scan-only; soft Gherkin on/off).
|
||||||
|
4. **Practical implication**: for "cheap", choosing two-pack already drops the expensive
|
||||||
|
layers (spec/architecture) — the main cost lever is the pack. Level is for scaling depth
|
||||||
|
WITHIN a pack (e.g. two-pack without mutation run, four-pack without Gherkin mutation).
|
||||||
|
The run confirmed it: the main waste was four-pack for a login form, not gate depth.
|
||||||
|
|
||||||
|
**Verdict**: the level proposal remains valid but is **narrower** than it first seemed: its
|
||||||
|
real value is depth within a pack, not replacing pack choice. Possible simplification: start
|
||||||
|
with only two levels (standard = current, light = no mutation run or Gherkin mutation) and
|
||||||
|
let pack choice do the rest.
|
||||||
|
|
||||||
|
## 6. Analysis: spec vs hardening priority (the critique of two-pack)
|
||||||
|
|
||||||
|
**two-pack's logic**: TDD already specifies behavior at the unit-test level; Gherkin is a
|
||||||
|
second layer (reviewable contract + end-to-end acceptance) that is expensive (APS pipeline);
|
||||||
|
hardening gates (CRAP≤6, DRY, mutation run) are the code quality floor.
|
||||||
|
|
||||||
|
**The critique (valid)**: for a small task the priority is inverted — mutation run is
|
||||||
|
expensive and protects code that may be thrown away in a prototype; cheap spec ensures the
|
||||||
|
RIGHT thing is built. A well-hardened but wrong feature is still wrong. Logical order:
|
||||||
|
first WHAT (spec), then HOW (gates).
|
||||||
|
|
||||||
|
| | two-pack | spec-first variant (proposal) |
|
||||||
|
|---|---|---|
|
||||||
|
| Base spec | unit tests (TDD) | Light Gherkin (reviewable contract + human approval) + TDD |
|
||||||
|
| Code protection | CRAP≤6 + DRY + **mutation run** | Reasonable CRAP/DRY, **no mutation run** |
|
||||||
|
| Cost | ~2-3x | ~1.5-2x |
|
||||||
|
| Risk covered | dirty/unchangeable code | **building the wrong thing** |
|
||||||
|
|
||||||
|
**The gap it reveals**: two-pack assumes Gherkin comes with the full APS pipeline cost
|
||||||
|
(parser + entrypoint generator + runtime + step handlers). It offers no "spec-lite" variant:
|
||||||
|
write Gherkin as a reviewable contract without building the pipeline or running mutation.
|
||||||
|
|
||||||
|
**Refinement of the `light` level**:
|
||||||
|
|
||||||
|
> `light` = Gherkin written as contract + human approval + TDD + reasonable CRAP/DRY —
|
||||||
|
> **no APS pipeline, no mutation run, no Gherkin mutation, no property tests**.
|
||||||
|
|
||||||
|
This variant covers the most important risk (is it the right thing? does a human approve?)
|
||||||
|
at lower cost than "mutation run without spec".
|
||||||
|
|
||||||
|
## 7. Spec-light: Gherkin or other alternatives?
|
||||||
|
|
||||||
|
**Comparison of options for a cheap, reviewable contract:**
|
||||||
|
|
||||||
|
| Option | Cost | Human pre-code contract | Real enforcement | Risk | Upgrade to spec-full |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| TDD tests as spec (two-pack) | ~1x | ❌ | ✅ | user sees behavior at the end | — |
|
||||||
|
| Prose criteria (markdown) | ~1x | ✅ imprecise | ❌ | ambiguity | rewrite |
|
||||||
|
| **Gherkin written-only (light)** | ~1.5x | ✅ precise | ❌ | **spec drift** | ✅ zero rewrite |
|
||||||
|
| Given/When/Then scenarios in markdown | ~1x | ✅ | ❌ | no standard format | medium rewrite |
|
||||||
|
|
||||||
|
**Key point**: in light, real enforcement comes from the coder's TDD — it turns each approved
|
||||||
|
scenario into unit tests. Gherkin remains a human contract + guide, not verification.
|
||||||
|
**Spec drift** risk is mitigated by a light workflow rule: *"the coder maps each approved
|
||||||
|
scenario to unit tests; the handoff/report confirms the scenario→tests mapping"*.
|
||||||
|
|
||||||
|
**Recommendation**: in four-pack, light = natural degradation — the specifier already writes
|
||||||
|
Gherkin and asks for approval; the back half of the pipeline is cut (the coder does not build
|
||||||
|
entrypoint generator/runtime/step handlers, implements with TDD mapping scenarios). If you
|
||||||
|
later scale to spec-full, the `.feature` files are already there — you only build the pipeline
|
||||||
|
around them.
|
||||||
|
|
||||||
|
**Recommended cheap add-on**: in light, the coder runs `gherkin-parser` ONLY to validate that
|
||||||
|
the spec parses (seconds, no pipeline build) — prevents broken Gherkin syntax from passing
|
||||||
|
as a contract.
|
||||||
|
|
||||||
|
**When to choose each**: never going to scale → prose or TDD-only; may scale → Gherkin
|
||||||
|
written-only (the format IS the upgrade path); human must approve before coding → Gherkin.
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# SwarmForge — Handoff protocol and deterministic pipeline
|
||||||
|
|
||||||
|
> Working document complementary to `swarmforge.md`.
|
||||||
|
> Example based on the full `six-pack` workflow (specifier → coder → cleaner → architect → hardender → QA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Handoff semantics (full example)
|
||||||
|
|
||||||
|
**Task**: *"Implement a shopping cart with tax calculation"* → stable task name: **`cart-tax`**. That name travels the entire chain unchanged.
|
||||||
|
|
||||||
|
### 1.1 The message contract
|
||||||
|
|
||||||
|
Only **two message types** exist, and only the headers the agent may write:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type: git_handoff → "I committed work; merge and process it"
|
||||||
|
to: coder
|
||||||
|
priority: 50 → 00 = urgent · 50 = normal · 99 = low
|
||||||
|
task: cart-tax → stable name that travels the chain
|
||||||
|
commit: 3f9a2c1d7e → canonical 10-hex hash (the gate validates and canonicalizes it)
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
type: note → short message (only if the constitution/role authorizes it)
|
||||||
|
to: architect
|
||||||
|
priority: 70
|
||||||
|
message: <1 line, max 80 chars>
|
||||||
|
```
|
||||||
|
|
||||||
|
Agents **never write the payload or reserved headers** (`id`, `from`, `role`, `recipient`, `created_at`, `enqueued_at`…): the tool generates all of that.
|
||||||
|
|
||||||
|
### 1.2 The specifier opens the chain
|
||||||
|
|
||||||
|
The specifier talks with you, writes `features/cart.feature` (Gherkin) + the end-to-end QA suite, and **asks for your explicit approval**. Only after your OK does it commit and write its draft:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type: git_handoff
|
||||||
|
to: coder
|
||||||
|
priority: 50
|
||||||
|
task: cart-tax
|
||||||
|
commit: 3f9a2c1d7e
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs `swarm_handoff.sh draft` → the **validation gate** does 4 checks: `coder` is a known role, `50` is a valid priority, the commit **resolves to exactly one object and is a commit** (via `git rev-parse --disambiguate`), and there are no reserved fields or agent-written body. It generates the payload and installs it atomically in the outbox:
|
||||||
|
|
||||||
|
```text
|
||||||
|
50_20260710T120000Z_000042_from_specifier_to_coder.handoff
|
||||||
|
```
|
||||||
|
|
||||||
|
The **daemon** (1 s polling) copies the file to the coder's `inbox/new/` **adding delivery headers**, and wakes the coder by typing into its tmux pane: *"You have new handoff mail. If idle, run ready_for_next.sh."* + Enter.
|
||||||
|
|
||||||
|
The delivered file (this is what the coder sees):
|
||||||
|
|
||||||
|
```text
|
||||||
|
id: 20260710T120000Z_000042_from_specifier
|
||||||
|
from: specifier
|
||||||
|
to: coder
|
||||||
|
recipient: coder ← added by the daemon (per-recipient copy)
|
||||||
|
priority: 50
|
||||||
|
type: git_handoff
|
||||||
|
role: specifier
|
||||||
|
task: cart-tax
|
||||||
|
commit: 3f9a2c1d7e
|
||||||
|
created_at: 2026-07-10T12:00:00Z
|
||||||
|
enqueued_at: 2026-07-10T12:00:01Z ← added by the daemon
|
||||||
|
|
||||||
|
Re-read your role and constitution.
|
||||||
|
|
||||||
|
merge_and_process specifier 3f9a2c1d7e
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 The coder consumes the task
|
||||||
|
|
||||||
|
The coder runs `ready_for_next.sh` → the helper moves the file from `inbox/new/` to `inbox/in_process/`, **adds `dequeued_at`**, and prints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TASK: .swarmforge/handoffs/inbox/in_process/50_..._from_specifier_to_coder.handoff
|
||||||
|
FROM: specifier
|
||||||
|
TYPE: git_handoff
|
||||||
|
PRIORITY: 50
|
||||||
|
TASK_NAME: cart-tax
|
||||||
|
PAYLOAD:
|
||||||
|
Re-read your role and constitution.
|
||||||
|
|
||||||
|
merge_and_process specifier 3f9a2c1d7e
|
||||||
|
```
|
||||||
|
|
||||||
|
The coder does `merge_and_process specifier 3f9a2c1d7e` (merge of the specification commit), applies **TDD** (unit tests first, then implementation), runs the acceptance tests generated from the Gherkin, commits with byline (*"Implement cart tax" — `By coder.`*) and **forwards along the chain** with the same `task: cart-tax` and its new commit. Key rule: **an intermediate role ALWAYS forwards**, no matter what (even if the change is format-only).
|
||||||
|
|
||||||
|
### 1.4 The cleaner in batch mode
|
||||||
|
|
||||||
|
The cleaner is configured in `swarmforge.conf` with `batch`. If 3 handoffs arrive from the coder at the same priority, `ready_for_next_batch.sh` groups them:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BATCH: .swarmforge/handoffs/inbox/in_process/batch_20260710T130000Z_000051
|
||||||
|
COUNT: 3
|
||||||
|
PRIORITY: 50
|
||||||
|
BATCH_ITEM: 1 → TASK_NAME: cart-tax ...
|
||||||
|
BATCH_ITEM: 2 → TASK_NAME: user-auth ...
|
||||||
|
BATCH_ITEM: 3 → TASK_NAME: cart-coupon ...
|
||||||
|
```
|
||||||
|
|
||||||
|
It processes all 3 as **one cleanup pass**: coverage, CRAP ≤ 6, DRY, mutation site scan (split files with >100 sites), acceptance + unit tests, commits, and forwards **once** to the architect.
|
||||||
|
|
||||||
|
### 1.5 The chain continues (architect → hardender → QA)
|
||||||
|
|
||||||
|
Each with the same mechanics: `ready_for_next.sh` (task or batch) → process its gate → verify → commit with byline → forward along the chain with `task: cart-tax` preserved.
|
||||||
|
|
||||||
|
### 1.6 QA closes: the "terminal broadcast"
|
||||||
|
|
||||||
|
When QA verifies everything (e2e UI suite, commit/manifest consistency, final CRAP/DRY), it commits and sends **a single handoff to multiple recipients** with `priority: 00`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type: git_handoff
|
||||||
|
to: specifier,coder,cleaner,architect,hardender
|
||||||
|
priority: 00
|
||||||
|
task: cart-tax
|
||||||
|
commit: b4d8e2f1a0
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the **exception to the forwarding rule**: each recipient does `merge_and_process QA b4d8e2f1a0`, runs its tests, and **does NOT forward**. The specifier, on receiving the broadcast, merges and asks you for the next feature. Chain closed.
|
||||||
|
|
||||||
|
### 1.7 Each task's state machine
|
||||||
|
|
||||||
|
```text
|
||||||
|
inbox/new/ ──ready_for_next──► inbox/in_process/ ──done_with_current──► inbox/completed/
|
||||||
|
(daemon delivery) (+dequeued_at) (+completed_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `done_with_current.sh` **picks up the next task or batch automatically** if there is a queue → agents do not sit idle.
|
||||||
|
- If a wake-up arrives while the agent is working → **it is ignored**; the queue is not lost because state lives in files.
|
||||||
|
- Swarm restart → agents re-run `ready_for_next.sh` and resume from `in_process`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Rules for a deterministic pipeline
|
||||||
|
|
||||||
|
Determinism does not come from one place: it comes from **four layers of rules** that reinforce each other.
|
||||||
|
|
||||||
|
### 2.1 Layer 1 — Shared rules (constitution)
|
||||||
|
|
||||||
|
**`workflow.prompt`** (work discipline):
|
||||||
|
|
||||||
|
- Each role works **only in its assigned worktree/branch**; forbidden to diff/merge foreign branches except via explicit handoff.
|
||||||
|
- Every commit carries a byline: `By <role>.`
|
||||||
|
- Temporary files under `./tmp/` of the worktree, not `/tmp`.
|
||||||
|
- If the expected git layout does not exist → **stop and report**, do not improvise.
|
||||||
|
|
||||||
|
**`handoffs.prompt`** (protocol):
|
||||||
|
|
||||||
|
- Only `git_handoff` and `note`; notes require explicit authorization.
|
||||||
|
- On ambiguity/contradiction → **stop and ask**, do not send notes.
|
||||||
|
- **Mandatory chain forwarding**: each intermediate role forwards to the next stage after completing, even if the change is non-functional (format, manifests, metadata).
|
||||||
|
- **Terminal broadcast = merge-only**: recipients of the final handoff do not forward.
|
||||||
|
- `task:` is preserved when forwarding; invent a stable name only for new work.
|
||||||
|
- Forbidden to edit/add/commit handoff runtime state.
|
||||||
|
|
||||||
|
**`engineering.prompt`** (technical rules):
|
||||||
|
|
||||||
|
- TDD: unit tests first, then minimal production to pass.
|
||||||
|
- Quality tools (mutation/CRAP/DRY/coverage) run only on **testable modules**; "environmentally unsuitable" modules remain as excluded adapters.
|
||||||
|
- Acceptance via `gherkin-parser` (APS) — forbidden to reimplement the parser.
|
||||||
|
- Local verification before each handoff; verification commands never concurrent with each other.
|
||||||
|
- Guardrails: do not edit mutation manifests by hand; do not commit unrelated artifacts.
|
||||||
|
|
||||||
|
### 2.2 Layer 2 — Per-role rules (six-pack)
|
||||||
|
|
||||||
|
| Role | Owns | **Does Not Own** (boundary) | Verification before handoff | Handoff obligation |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **specifier** | Gherkin + acceptance criteria + e2e QA suite | Does not run mutation or quality tools | Tests if needed; **nothing more** | **Does not commit or forward without your approval**. After your OK: commit + handoff to coder with invented `task:` |
|
||||||
|
| **coder** | Implementation of approved slices with TDD | QA suite, mutation, CRAP/DRY, Gherkin mutation | Unit tests + acceptance tests | Commit + handoff to cleaner |
|
||||||
|
| **cleaner** (batch) | Cleanup preserving behavior: names, duplication, boundaries, coverage | Mutation tests, Gherkin mutation, **new behavior** | CRAP ≤ 6, DRY, mutation site scan, acceptance + unit | Commit + handoff to architect **before taking another task/batch** |
|
||||||
|
| **architect** (batch) | Structure, boundaries, dependency direction, mutation hardening, DRY, property tests | — (inherits the chain) | Per-file mutation (differential), DRY, property tests, Gherkin soft | Commit + handoff to hardender |
|
||||||
|
| **hardender** (batch) | Mutation hardening (kill survivors), Gherkin mutation, final CRAP/DRY | Specifier's e2e QA suite | Mutation → Gherkin soft → CRAP → DRY | Commit + handoff to QA |
|
||||||
|
| **QA** (batch) | Independent final verification, turn QA suite into executable scripts, e2e via UI | Mutation and Gherkin mutation | e2e UI suite, handoff/manifest consistency, CRAP/DRY | Commit + **broadcast priority 00 to all** (merge-only) |
|
||||||
|
|
||||||
|
### 2.3 Layer 3 — Transport rules (the gate)
|
||||||
|
|
||||||
|
- `swarm_handoff.sh` **rejects** drafts with: reserved fields, unknown roles, non-numeric priority (00–99), ambiguous or non-commit commits, `task` > 80 chars, agent-written bodies. The agent repairs and retries; nothing malformed enters the queue.
|
||||||
|
- Priorities: **50** = normal chain progress, **00** = terminal broadcast / urgent follow-up work. The queue orders by `priority_timestamp_sequence`, so order is **deterministic even if they arrive in the same second**.
|
||||||
|
- `batch` roles consume **all equal-priority handoffs as one unit** → cleaner/reviewer does not interrupt its pass for each delivery.
|
||||||
|
- Agents **do not talk to tmux**: the daemon is the only one with socket access; agents only write files to their outbox. Control channel and state channel are separated.
|
||||||
|
|
||||||
|
### 2.4 Layer 4 — State rules (the queue as a state machine)
|
||||||
|
|
||||||
|
- `new → in_process → completed` with audit timestamps (`enqueued_at`, `dequeued_at`, `completed_at`).
|
||||||
|
- **Resumption**: state lives in files, not memory — you restart the swarm and `ready_for_next.sh` resumes from `in_process`.
|
||||||
|
- `done_with_current.sh` **chains the next task** automatically → the pipeline advances without human intervention between gates.
|
||||||
|
|
||||||
|
### 2.5 Where determinism comes from (summary)
|
||||||
|
|
||||||
|
1. **Closed message types** (2) and **strict validation gate** → nothing ambiguous enters the system.
|
||||||
|
2. **Mandatory chain forwarding** + **merge-only broadcast** → processing order is always the same, with no skips or loops.
|
||||||
|
3. **Ownership boundaries** ("Does Not Own") → each agent only touches its own work; nobody steps on another's (coder does not do mutation; cleaner does not introduce behavior).
|
||||||
|
4. **Mandatory verification before each handoff** → a handoff only exists if its gate passed.
|
||||||
|
5. **Worktree isolation** → each role sees only its branch; merge happens explicitly via `merge_and_process` at handoff time.
|
||||||
|
6. **Stable task name + priority + sequence** → full traceability: you can follow `cart-tax` commit by commit through the whole chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Diagrams
|
||||||
|
|
||||||
|
### 3.1 Full pipeline (six roles, `six-pack`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant U as User
|
||||||
|
participant S as Specifier
|
||||||
|
participant C as Coder
|
||||||
|
participant CL as Cleaner (batch)
|
||||||
|
participant A as Architect (batch)
|
||||||
|
participant H as Hardender (batch)
|
||||||
|
participant Q as QA (batch)
|
||||||
|
|
||||||
|
U->>S: Implement cart with tax
|
||||||
|
S->>U: Gherkin + e2e QA suite (asks approval)
|
||||||
|
U-->>S: Approved
|
||||||
|
S->>S: commit spec + draft (type/to/priority/task/commit)
|
||||||
|
S->>S: swarm_handoff.sh → outbox (gate: canonical commit)
|
||||||
|
Note over S,C: daemon delivers to coder inbox/new + tmux wake-up
|
||||||
|
C->>C: ready_for_next.sh → in_process + TASK cart-tax
|
||||||
|
C->>C: merge_and_process specifier `<commit>` + TDD + acceptance
|
||||||
|
C->>C: commit + byline + forward (same task)
|
||||||
|
Note over C,CL: daemon delivers (several equal-priority handoffs)
|
||||||
|
CL->>CL: ready_for_next.sh → BATCH (N items)
|
||||||
|
CL->>CL: CRAP ≤ 6 + DRY + mutation scan + tests
|
||||||
|
CL->>CL: commit + forward to architect
|
||||||
|
A->>A: structure + dependencies + differential mutation + DRY
|
||||||
|
A->>A: commit + forward to hardender
|
||||||
|
H->>H: mutation hardening + Gherkin soft + CRAP/DRY
|
||||||
|
H->>H: commit + forward to QA
|
||||||
|
Q->>Q: e2e UI suite + handoff consistency
|
||||||
|
Q->>Q: commit + broadcast priority 00 (merge-only)
|
||||||
|
Q-->>S: merge_and_process QA `<commit>` — no forward
|
||||||
|
S->>U: Next feature?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Handoff chain and priorities
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U[User] -->|"intent"| S[Specifier]
|
||||||
|
S -->|"git_handoff p50 · stable task"| C[Coder]
|
||||||
|
C -->|"git_handoff p50"| CL[Cleaner · batch]
|
||||||
|
CL -->|"git_handoff p50"| A[Architect · batch]
|
||||||
|
A -->|"git_handoff p50"| H[Hardender · batch]
|
||||||
|
H -->|"git_handoff p50"| Q[QA · batch]
|
||||||
|
Q -->|"git_handoff p00 · broadcast merge-only"| S
|
||||||
|
S -.->|"human approval"| U
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Task lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> new: daemon delivers .handoff
|
||||||
|
new --> in_process: ready_for_next.sh (dequeued_at)
|
||||||
|
in_process --> completed: done_with_current.sh (completed_at)
|
||||||
|
in_process --> in_process: next queued task or batch
|
||||||
|
new --> [*]: NO_TASK (empty queue)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mermaid syntax notes (validated with v11.13.0)
|
||||||
|
|
||||||
|
- In `sequenceDiagram` messages do not use `<`/`>` entities — use backticks: `` `<commit>` ``.
|
||||||
|
- In `flowchart` labels do not use escaped double quotes (`\"`) — use inner single quotes or plain text.
|
||||||
|
- `<br/>` does work inside sequence messages and labels.
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# SwarmForge — Description, protocol, and migration requirements
|
||||||
|
|
||||||
|
> Working document. Project source: https://github.com/unclebob/swarm-forge
|
||||||
|
> Goal: understand SwarmForge's architecture and evaluate adapting it to **pi** as the agent, on **Linux**, with **DeepSeek / GLM / Qwen** models.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project description
|
||||||
|
|
||||||
|
**SwarmForge** is a **tmux**-based agent orchestration platform that turns a swarm of AI agents into a coordinated software engineering team. It was created by Robert C. Martin and applies his own engineering discipline (TDD, Gherkin/acceptance testing, mutation testing, CRAP/DRY analysis) to the problem of coordinating agents.
|
||||||
|
|
||||||
|
Core idea: **each agent lives in its own git worktree and its own tmux session**, and agents communicate via a **file-based handoff protocol** delivered by a daemon. There are no direct messages between agents and no direct access to the tmux socket by them.
|
||||||
|
|
||||||
|
### Branch structure
|
||||||
|
|
||||||
|
| Branch | Description | Roles |
|
||||||
|
|---|---|---|
|
||||||
|
| `main` | **Documentary**: shared operational scripts + default constitution articles | — |
|
||||||
|
| `two-pack` | Fast backend workflow (TDD + hardening, no Gherkin) | `coder` → `cleaner` → `coder` |
|
||||||
|
| `four-pack` | Compact workflow with Gherkin specification | `specifier` → `coder` → `refactorer` → `architect` → `specifier` |
|
||||||
|
| `six-pack` | Full workflow with all quality gates separated | `specifier` → `coder` → `cleaner` → `architect` → `hardender` → `QA` → end |
|
||||||
|
|
||||||
|
Each executable branch contains the project config: `swarmforge.conf` (topology), `roles/<role>.prompt` (per-role prompts) and `constitution.prompt` + articles (shared rules). On startup, the `./swarm` wrapper downloads the shared operational scripts from `main` (first time only) and launches the orchestrator.
|
||||||
|
|
||||||
|
### How it works at a high level
|
||||||
|
|
||||||
|
1. **Declarative configuration**: `swarmforge.conf` defines the swarm window by window:
|
||||||
|
```
|
||||||
|
window <role> <agent> <worktree> [task|batch] [extra-args...]
|
||||||
|
```
|
||||||
|
2. **Launcher** (`swarmforge.bb`, Babashka): validates the config, initializes the git repo if needed, creates a **worktree per role** under `.worktrees/`, creates a **tmux session per role** on a project-owned socket and launches each agent with its initial prompt.
|
||||||
|
3. **Agents**: each runs as an interactive TUI in its tmux pane, inside its worktree, with the handoff scripts on its `PATH`.
|
||||||
|
4. **Daemon** (`handoffd.bb`): owner of the tmux socket. Watches agent outboxes, delivers handoffs to recipient inboxes and wakes agents with a message typed into their pane.
|
||||||
|
5. **Handoff protocol**: agents create validated drafts, receive them as tasks or batches (`task`/`batch`), and report completion with `done_with_current.sh`.
|
||||||
|
6. **Optional viewer**: terminal adapters (`terminal-adapters/*.sh`) open one window per role for real-time observation, with a watchdog that reopens closed windows without losing agent state.
|
||||||
|
|
||||||
|
### Key features
|
||||||
|
|
||||||
|
- **Config-driven topology**: swarm shape comes from `swarmforge.conf`, not from code.
|
||||||
|
- **Per-project roles**: `swarmforge/roles/<role>.prompt` per branch/backlog.
|
||||||
|
- **Layered constitution**: `constitution.prompt` directs agents to read articles under `swarmforge/constitution/articles/` (shared engineering, handoff and workflow rules + local per-branch rules).
|
||||||
|
- **Per-role backends**: each role can use a different agent CLI (`claude`, `codex`, `copilot`, `grok`).
|
||||||
|
- **Observable**: one terminal window per role, or headless in tmux.
|
||||||
|
- **Self-hosted and light**: only needs tmux, git, zsh and Babashka; all state lives in `.swarmforge/` inside the project.
|
||||||
|
- **Operational robustness**: host sleep prevention (`caffeinate`/`systemd-inhibit`), task resumption after restart, file-based audit (`new` → `in_process` → `completed`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Handoff protocol (summary)
|
||||||
|
|
||||||
|
The protocol separates **state** (files on the filesystem, durable and auditable) from **control** (tmux, only for notification and liveness).
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
Only two types, both strictly validated:
|
||||||
|
|
||||||
|
```
|
||||||
|
type: git_handoff type: note
|
||||||
|
to: <role>[,<role>...] to: <role>[,<role>...]
|
||||||
|
priority: NN (00-99) priority: NN (00-99)
|
||||||
|
task: <stable-name> message: <1 line, max 80 chars>
|
||||||
|
commit: <10 hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `git_handoff`: the sender has committed work; the receiver does `merge_and_process <role> <commit>`.
|
||||||
|
- `note`: short message; only when the constitution or role explicitly authorizes it.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. The agent commits and writes a **draft** with headers only.
|
||||||
|
2. `swarm_handoff.sh` is the **validation gate**: rejects reserved fields, unknown roles, invalid priorities, ambiguous commits (canonicalizes the hash with `git rev-parse --disambiguate`) and bodies that are not generated.
|
||||||
|
3. The helper generates the payload (`id`, `from`, `role`, `task`, `created_at`, body) and installs it atomically in `outbox/`.
|
||||||
|
4. The **daemon** polls (1s), copies the handoff to each recipient's `inbox/new/` (adding `recipient` and `enqueued_at`) and wakes the receiver.
|
||||||
|
5. The receiver runs `ready_for_next.sh` → moves to `inbox/in_process/` (adds `dequeued_at`) and prints `TASK:`/`BATCH:` with the payload.
|
||||||
|
6. On completion, `done_with_current.sh` moves to `inbox/completed/` (adds `completed_at`) and picks up the next task if one exists.
|
||||||
|
7. The daemon moves the sender's original to `sent/` or `failed/`.
|
||||||
|
|
||||||
|
### Wake-up (control plane)
|
||||||
|
|
||||||
|
The daemon "wakes" an agent by typing into its tmux pane:
|
||||||
|
|
||||||
|
```
|
||||||
|
tmux send-keys -t <session> -l "You have new handoff mail. If idle, run ready_for_next.sh."
|
||||||
|
tmux send-keys -t <session> C-m # Enter
|
||||||
|
tmux send-keys -t <session> C-j # robustness LF
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent receives it as a user message. Protocol rules: if it is already working, **ignore the wake-up**; `done_with_current.sh` picks up the next task when finished. In practice, an agent with a message queue (like pi) enqueues the wake-up and delivers it when the turn ends.
|
||||||
|
|
||||||
|
### Chain rules
|
||||||
|
|
||||||
|
- Intermediate roles **always forward** a `git_handoff` to the next role in the chain, no matter what (even if the change is non-functional).
|
||||||
|
- The final handoff of the chain (broadcast) is **merge-only**: recipients merge and do not forward.
|
||||||
|
- Task names (`task:`) are preserved along the chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Migration requirements
|
||||||
|
|
||||||
|
### 3.1 Agent contract (necessary condition)
|
||||||
|
|
||||||
|
SwarmForge requires the agent to be a **long-lived interactive process in a tmux pane** that satisfies:
|
||||||
|
|
||||||
|
1. **Interactive CLI (TUI/REPL)** that keeps running — wake-ups arrive as typed text + Enter; a one-shot CLI cannot receive work.
|
||||||
|
2. **Initial prompt via command line** (or injectable via `tmux send-keys` after startup).
|
||||||
|
3. **Work in the worktree directory** (`cd <worktree> && <agent> ...`).
|
||||||
|
4. **Ability to run commands** (the helpers `swarm_handoff.sh`, `ready_for_next.sh`, `done_with_current.sh` are shell/bb on `PATH` — they are model-agnostic).
|
||||||
|
|
||||||
|
Everything else in the protocol (handoffs, worktrees, daemon, wake-ups, watchdog) **does not know about the model**: the only integration point is the launch arm in `swarmforge.bb` and the validated backend list in `parse-config`.
|
||||||
|
|
||||||
|
### 3.2 Validation: pi as agent
|
||||||
|
|
||||||
|
**Fits out of the box.** Verified in docs and installed binary:
|
||||||
|
|
||||||
|
- `pi "<prompt>"` starts the TUI, **sends the initial message and stays interactive** (confirmed in `dist/modes/interactive/interactive-mode.js`).
|
||||||
|
- **tmux officially supported** (`docs/tmux.md`). Recommendation: tmux ≥ 3.5 with `extended-keys-format csi-u` for modified keys; the basic protocol (Enter) works with any version.
|
||||||
|
- **Compatible wake-up**: in pi `Enter` = send, `Ctrl+J` = new line (the daemon's `C-j` is harmless).
|
||||||
|
- **Message queue**: a message typed while pi is working is **enqueued and delivered when the turn ends** — ideal for the protocol's wake-up semantics.
|
||||||
|
- **Sessions**: `pi -c` (continue), `--session`, `--name "SwarmForge <Role>"` (session name).
|
||||||
|
|
||||||
|
Operational requirements with pi:
|
||||||
|
|
||||||
|
| Requirement | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Trust prompt | pi asks on startup in a new project and **would block the agent**. Use `-a/--approve` in the launch arm or pre-seed `~/.pi/agent/trust.json`. |
|
||||||
|
| Fixed model | Use `--model <provider>/<model>` so each role does not start at the login selector. |
|
||||||
|
| Runtime | Node.js ≥ 22 (npm install) or standalone script; Linux supported natively. |
|
||||||
|
|
||||||
|
Proposed launch arm in `swarmforge.bb`:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
"pi" (str "pi -a --name " (sq (str "SwarmForge " display))
|
||||||
|
" --model " (sq model) " "
|
||||||
|
(extra-args-prefix row)
|
||||||
|
"\"$(cat " (sq (str prompt-file)) ")\"")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Validation: opencode as agent
|
||||||
|
|
||||||
|
**Fits with a mandatory adaptation.** Verified on the real v1.18.15 binary and in source:
|
||||||
|
|
||||||
|
- `opencode` (no args) starts the persistent **interactive TUI**.
|
||||||
|
- ⚠️ The TUI **does not accept an initial message via CLI**: `--prompt` in TUI mode calls a Node `rl.question` (waits for stdin input); only `--mini --prompt` sends it as a message, but with `interactive: false` (runs and exits). `opencode run "<msg>"` is **headless one-shot** — not usable as a swarm agent.
|
||||||
|
- **Solution**: launch the TUI (`opencode --auto`) and **inject the prompt with `tmux send-keys`** after startup — the same mechanism the daemon already uses to wake. About ~10 lines in `launch-role!` (launch → sleep → `send-keys -l "$(cat prompt)"` + Enter).
|
||||||
|
|
||||||
|
```
|
||||||
|
opencode --auto -m <provider>/<model> # in the role's tmux session
|
||||||
|
# after ~2s:
|
||||||
|
tmux send-keys -t <target> -l "<initial prompt>" ; tmux send-keys -t <target> C-m
|
||||||
|
```
|
||||||
|
|
||||||
|
Operational requirements with opencode:
|
||||||
|
|
||||||
|
| Requirement | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Permissions | `--auto` (auto-approve; also the hidden aliases `--yolo` / `--dangerously-skip-permissions`) — equivalent to the swarm's autonomous mode. |
|
||||||
|
| Sessions | `-c/--continue`, `-s/--session` for the restart flow ("on restart, run ready_for_next.sh"). |
|
||||||
|
| Runtime | Static binary (npm `opencode-ai` or GitHub release); Linux supported. |
|
||||||
|
| Future | `opencode serve` + `attach`/SDK/ACP would allow a native message queue without keyboard wake-ups (would require changing the architecture, not adapting it). |
|
||||||
|
|
||||||
|
### 3.4 Models: DeepSeek / GLM / Qwen
|
||||||
|
|
||||||
|
| Model | pi | opencode |
|
||||||
|
|---|---|---|
|
||||||
|
| **DeepSeek** | **Native**: `DEEPSEEK_API_KEY`, provider `deepseek`, `--model deepseek/...` | **Native** in catalog (`models.dev`): `deepseek-*` |
|
||||||
|
| **Qwen** | **Native**: `QWEN_TOKEN_PLAN_API_KEY`, providers `qwen-token-plan` / `-individual` / `-cn` (China) | **Native**: `qwen3.x-*`, `alibaba-*/qwen*` |
|
||||||
|
| **GLM (Zhipu)** | **Not native**: needs a custom provider extension (OpenAI-compatible, `api: "openai-completions"`, `thinkingFormat: "zai"`) or OpenAI-compatible proxy | **Native**: `glm-4.x`/`glm-5.x` (`opencode-go/glm-*`, `alibaba-*/glm-*`) |
|
||||||
|
|
||||||
|
Note: pi already implements the *thinking* formats of all three families (`thinkingFormat: "deepseek" | "zai" | "qwen"` in `docs/custom-provider.md`), which simplifies GLM integration: you only need to register the endpoint and models with that extension.
|
||||||
|
|
||||||
|
### 3.5 Linux (runtime)
|
||||||
|
|
||||||
|
| Requirement | Status | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| `zsh` | **Hard requirement** | Scripts use `#!/usr/bin/env zsh`. Arch: `pacman -S zsh`. |
|
||||||
|
| `tmux` | Required | Recommended ≥ 3.5 (pi with extended keys). |
|
||||||
|
| `git` | Required | Worktrees and commit protocol. |
|
||||||
|
| Babashka (`bb`) | Required | Launcher and all helpers are Babashka (cross-platform). |
|
||||||
|
| Node.js ≥ 22 | pi only | npm install of pi (or standalone script). |
|
||||||
|
| Terminal | **Headless works** | By default on Linux (no `osascript`/`wt.exe`) the launcher falls back to `none`: attaches the current shell to the first role's session and the rest stay detached (`tmux -S <socket> attach -t swarmforge-<role>`). The swarm runs fully without windows. |
|
||||||
|
| Automatic windows (optional) | To build | Write a `terminal-adapters/wezterm.sh` (or kitty) for Linux: 5-function contract (~40 lines). WezTerm is the most scriptable (`wezterm cli`); Ghostty on Linux has no remote control. |
|
||||||
|
| Shutdown | Plan for | The `close-swarm` script lives on the `main` branch; executable branches do not carry it — copy it into the project or use shutdown by "closing the first window". |
|
||||||
|
| Sleep prevention | Works | `systemd-inhibit` on Linux (systemd running). Disable with `SWARMFORGE_PREVENT_SLEEP=0`. |
|
||||||
|
|
||||||
|
### 3.6 Necessary code changes (minimal)
|
||||||
|
|
||||||
|
In `swarmforge/scripts/swarmforge.bb` (the working branch, e.g. `four-pack`):
|
||||||
|
|
||||||
|
1. **`parse-config`**: add the backend to the validated list, e.g. `#{"claude" "codex" "copilot" "grok" "pi"}`.
|
||||||
|
2. **`launch-command`**: add the new backend's arm (pi: section 3.2; opencode: section 3.3).
|
||||||
|
3. **`check-backend-dependencies!`**: no changes — already checks that the binary exists on `PATH`.
|
||||||
|
|
||||||
|
In the project config:
|
||||||
|
|
||||||
|
- `swarmforge.conf`: `window coder pi master` (or `opencode`), with `[task|batch]` and extra args per role.
|
||||||
|
|
||||||
|
Optional depending on goal:
|
||||||
|
|
||||||
|
- Shared constitution articles in `swarmforge/constitution/articles/` of the branch (the wrapper only *stages* them in `scripts/shared-articles/`; confirm agents read what the branch needs).
|
||||||
|
- Linux terminal adapter (section 3.5).
|
||||||
|
- `close-swarm` in the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Config["Configuration (per project/branch)"]
|
||||||
|
CONF["swarmforge.conf<br/>window role agent worktree [task|batch] [args]"]
|
||||||
|
ROLES["swarmforge/roles/<role>.prompt"]
|
||||||
|
CONST["swarmforge/constitution.prompt<br/>+ constitution/articles/"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Launcher["Launcher — swarmforge.bb (Babashka)"]
|
||||||
|
PARSE["Validate config and prompts"]
|
||||||
|
WT["Git worktrees<br/>.worktrees/<role> (branch per role)"]
|
||||||
|
TMUX["tmux sessions<br/>swarmforge-<role> · project-owned socket"]
|
||||||
|
LAUNCH["send-keys: export SWARMFORGE_ROLE<br/>+ PATH helpers + cd worktree<br/>+ <agent> '$(cat prompt)'"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Swarm["Swarm (1 agent per role)"]
|
||||||
|
A1["Agent TUI<br/>(tmux pane)"]
|
||||||
|
A2["Agent TUI<br/>(tmux pane)"]
|
||||||
|
A3["Agent TUI<br/>(tmux pane)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph State["Durable state — filesystem (.swarmforge/handoffs)"]
|
||||||
|
OUT["outbox/ · sent/ · failed/"]
|
||||||
|
IN["inbox/ new · in_process · completed"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Control["Control — daemon handoffd.bb"]
|
||||||
|
DAEMON["Poll outbox → deliver to inbox<br/>→ wake-up via tmux send-keys"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Viewer["Viewer (optional)"]
|
||||||
|
ADAPT["terminal-adapters/*.sh"]
|
||||||
|
WATCH["swarm-window-watchdog"]
|
||||||
|
end
|
||||||
|
|
||||||
|
CONF --> PARSE
|
||||||
|
ROLES --> PARSE
|
||||||
|
CONST --> PARSE
|
||||||
|
PARSE --> WT --> LAUNCH
|
||||||
|
PARSE --> TMUX --> LAUNCH
|
||||||
|
LAUNCH --> A1 & A2 & A3
|
||||||
|
A1 & A2 & A3 -->|"helpers on PATH:<br/>swarm_handoff.sh"| OUT
|
||||||
|
OUT --> DAEMON
|
||||||
|
DAEMON -->|"deliver .handoff"| IN
|
||||||
|
DAEMON -->|"wake-up: text + Enter"| A1 & A2 & A3
|
||||||
|
A1 & A2 & A3 -->|"ready_for_next.sh<br/>done_with_current.sh"| IN
|
||||||
|
A1 & A2 & A3 -->|"work (git)"| WT
|
||||||
|
A1 & A2 & A3 -->|"observe: tmux attach"| TMUX
|
||||||
|
TMUX --> ADAPT --> WATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Protocol diagram (one handoff cycle)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant S as Sender agent (e.g. coder)
|
||||||
|
participant V as swarm_handoff.sh (gate)
|
||||||
|
participant O as outbox/ (sender)
|
||||||
|
participant D as Daemon handoffd.bb
|
||||||
|
participant I as inbox/ (receiver)
|
||||||
|
participant R as Receiver agent (e.g. cleaner)
|
||||||
|
|
||||||
|
Note over S: git commit (message with role byline)
|
||||||
|
S->>S: Write draft (type/to/priority/task/commit)
|
||||||
|
S->>V: swarm_handoff.sh `<draft>`
|
||||||
|
V->>V: Validate: known roles, priority 00-99,<br/>canonical commit (10 hex, --disambiguate),<br/>reserved fields, body forbidden
|
||||||
|
V->>O: Install generated .handoff (id, from, role,<br/>task, created_at, merge_and_process payload)
|
||||||
|
V-->>S: HANDOFF QUEUED
|
||||||
|
O->>D: Poll (1 s)
|
||||||
|
D->>I: Copy to each recipient inbox/new/<br/>+ recipient, enqueued_at headers
|
||||||
|
D->>R: tmux send-keys -l 'You have new handoff mail...'<br/>+ C-m (Enter) + C-j (robustness)
|
||||||
|
Note over R: If busy → ignore (queue or next<br/>done_with_current will pick it up)
|
||||||
|
R->>I: ready_for_next.sh → move to in_process/<br/>+ dequeued_at header
|
||||||
|
I-->>R: TASK: `<path>` / BATCH: `<items>` + PAYLOAD
|
||||||
|
R->>R: merge_and_process `<sender>` `<commit>`<br/>+ process the task in its worktree
|
||||||
|
R->>I: done_with_current.sh → completed/<br/>+ completed_at header
|
||||||
|
I-->>R: Next task or NO_TASK
|
||||||
|
D->>O: Move original to sent/ (or failed/)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inbox task lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> new: daemon delivers .handoff
|
||||||
|
new --> in_process: ready_for_next.sh (dequeued_at)
|
||||||
|
in_process --> completed: done_with_current.sh (completed_at)
|
||||||
|
in_process --> in_process: next queued task
|
||||||
|
new --> [*]: NO_TASK (empty queue)
|
||||||
|
failed --> [*]: delivery error (sender outbox)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Executive summary
|
||||||
|
|
||||||
|
1. **The architecture is model-agnostic**: the only integration point for a new backend is the launch arm + the validated backend list in `swarmforge.bb`; the handoff protocol, worktrees, daemon and wake-ups do not know about the agent.
|
||||||
|
2. **pi fits directly**: interactive initial message via CLI, tmux supported, message queue aligned with wake-up semantics, native DeepSeek/Qwen and GLM with a small extension.
|
||||||
|
3. **opencode fits with an adaptation**: the TUI does not accept an initial prompt via CLI → inject via `tmux send-keys` after startup (mechanism already in the system). Native DeepSeek/GLM/Qwen.
|
||||||
|
4. **Linux is a first-class citizen by design**: the swarm lives in tmux, not in windows; headless runs fully. Automatic windows are only an optional terminal adapter.
|
||||||
|
5. **Minimum requirements**: zsh + tmux (≥3.5 recommended) + git + Babashka + (Node.js for pi) + ~15 lines of changes in `swarmforge.bb` + provider config.
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MAIN_BRANCH="${SWARMFORGE_SCRIPTS_BRANCH:-main}"
|
||||||
|
ARCHIVE_URL="${SWARMFORGE_SCRIPTS_URL:-https://github.com/unclebob/swarm-forge/archive/refs/heads/${MAIN_BRANCH}.tar.gz}"
|
||||||
|
TMP_DIR=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$TMP_DIR" ]]; then
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -d "$SCRIPT_DIR/swarmforge/scripts" || ! -d "$SCRIPT_DIR/swarmforge/scripts/shared-articles" ]]; then
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
mkdir -p "$SCRIPT_DIR/swarmforge"
|
||||||
|
curl -L "$ARCHIVE_URL" | tar -xz --strip-components=1 -C "$TMP_DIR"
|
||||||
|
if [[ ! -d "$SCRIPT_DIR/swarmforge/scripts" ]]; then
|
||||||
|
cp -R "$TMP_DIR/swarmforge/scripts" "$SCRIPT_DIR/swarmforge/scripts"
|
||||||
|
fi
|
||||||
|
if [[ -d "$TMP_DIR/swarmforge/constitution/articles" ]]; then
|
||||||
|
mkdir -p "$SCRIPT_DIR/swarmforge/scripts/shared-articles"
|
||||||
|
cp -R "$TMP_DIR/swarmforge/constitution/articles/." "$SCRIPT_DIR/swarmforge/scripts/shared-articles/"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$SCRIPT_DIR/swarmforge/scripts/swarmforge.sh" "$@"
|
||||||
@@ -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,44 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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" "$@"
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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*)
|
||||||
Executable
+202
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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
@@ -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*)
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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*)
|
||||||
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
exec bb "$SCRIPT_DIR/swarm_handoff.bb" "$@"
|
||||||
Executable
+603
@@ -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*)
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
exec bb "$SCRIPT_DIR/swarmforge.bb" "$@"
|
||||||
+110
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
(ns swarmforge.handoff-test
|
||||||
|
(:require [babashka.fs :as fs]
|
||||||
|
[clojure.java.shell :as sh]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clojure.test :refer [deftest is run-tests testing use-fixtures]]))
|
||||||
|
|
||||||
|
(def repo-root (fs/cwd))
|
||||||
|
(def scripts-dir (fs/path repo-root "swarmforge" "scripts"))
|
||||||
|
(def temp-dirs (atom []))
|
||||||
|
|
||||||
|
(use-fixtures :once
|
||||||
|
(fn [tests]
|
||||||
|
(try
|
||||||
|
(tests)
|
||||||
|
(finally
|
||||||
|
(doseq [dir @temp-dirs]
|
||||||
|
(fs/delete-tree dir))))))
|
||||||
|
|
||||||
|
(defn script [name]
|
||||||
|
(str (fs/path scripts-dir name)))
|
||||||
|
|
||||||
|
(defn tmp-dir []
|
||||||
|
(let [dir (fs/create-temp-dir {:prefix "swarmforge-handoff-test."})]
|
||||||
|
(swap! temp-dirs conj dir)
|
||||||
|
dir))
|
||||||
|
|
||||||
|
(defn run
|
||||||
|
[{:keys [dir env ok?]} & args]
|
||||||
|
(let [result (apply sh/sh (concat args [:dir (str dir)
|
||||||
|
:env (merge {"PATH" (System/getenv "PATH")
|
||||||
|
"GIT_CONFIG_NOSYSTEM" "1"}
|
||||||
|
env)]))]
|
||||||
|
(when (and (not (false? ok?)) (not= 0 (:exit result)))
|
||||||
|
(throw (ex-info (str "Command failed: " (str/join " " args))
|
||||||
|
(assoc result :args args))))
|
||||||
|
result))
|
||||||
|
|
||||||
|
(defn write-file [path text]
|
||||||
|
(fs/create-dirs (fs/parent path))
|
||||||
|
(spit (str path) text))
|
||||||
|
|
||||||
|
(defn read-file [path]
|
||||||
|
(slurp (str path)))
|
||||||
|
|
||||||
|
(defn init-repo! [root]
|
||||||
|
(run {:dir root} "git" "init" "-q")
|
||||||
|
(run {:dir root} "git" "config" "user.email" "test@example.com")
|
||||||
|
(run {:dir root} "git" "config" "user.name" "Test User")
|
||||||
|
(write-file (fs/path root "README.md") "initial\n")
|
||||||
|
(run {:dir root} "git" "add" "README.md")
|
||||||
|
(run {:dir root} "git" "commit" "-q" "-m" "Initial commit")
|
||||||
|
(str/trim (:out (run {:dir root} "git" "rev-parse" "--short=10" "HEAD"))))
|
||||||
|
|
||||||
|
(defn setup-project!
|
||||||
|
([root] (setup-project! root {"sender" "task" "receiver" "task"}))
|
||||||
|
([root roles]
|
||||||
|
(doseq [dir [".swarmforge/handoffs/outbox/tmp"
|
||||||
|
".swarmforge/handoffs/sent"
|
||||||
|
".swarmforge/handoffs/failed"
|
||||||
|
".swarmforge/handoffs/inbox/new"
|
||||||
|
".swarmforge/handoffs/inbox/in_process"
|
||||||
|
".swarmforge/handoffs/inbox/completed"]]
|
||||||
|
(fs/create-dirs (fs/path root dir)))
|
||||||
|
(write-file
|
||||||
|
(fs/path root ".swarmforge/roles.tsv")
|
||||||
|
(apply str
|
||||||
|
(for [[role mode] roles]
|
||||||
|
(format "%s\tmaster\t%s\tsession\t%s\tcodex\t%s\n"
|
||||||
|
role root (str/capitalize role) mode))))))
|
||||||
|
|
||||||
|
(defn handoff
|
||||||
|
[{:keys [id from to recipient priority type task commit body
|
||||||
|
enqueued-at dequeued-at completed-at]}]
|
||||||
|
(str "id: " id "\n"
|
||||||
|
"from: " from "\n"
|
||||||
|
"to: " to "\n"
|
||||||
|
(when recipient (str "recipient: " recipient "\n"))
|
||||||
|
"priority: " priority "\n"
|
||||||
|
"type: " type "\n"
|
||||||
|
(when task (str "task: " task "\n"))
|
||||||
|
(when commit (str "commit: " commit "\n"))
|
||||||
|
(when enqueued-at (str "enqueued_at: " enqueued-at "\n"))
|
||||||
|
(when dequeued-at (str "dequeued_at: " dequeued-at "\n"))
|
||||||
|
(when completed-at (str "completed_at: " completed-at "\n"))
|
||||||
|
"\n"
|
||||||
|
(or body (str "payload for " id)) "\n"))
|
||||||
|
|
||||||
|
(defn handoff-path [root state filename]
|
||||||
|
(fs/path root ".swarmforge" "handoffs" "inbox" state filename))
|
||||||
|
|
||||||
|
(defn put-handoff! [root state filename attrs]
|
||||||
|
(let [path (handoff-path root state filename)]
|
||||||
|
(write-file path (handoff attrs))
|
||||||
|
path))
|
||||||
|
|
||||||
|
(defn header [path field]
|
||||||
|
(some->> (str/split-lines (read-file path))
|
||||||
|
(take-while seq)
|
||||||
|
(some (fn [line]
|
||||||
|
(let [prefix (str field ": ")]
|
||||||
|
(when (str/starts-with? line prefix)
|
||||||
|
(subs line (count prefix))))))))
|
||||||
|
|
||||||
|
(defn make-queued-handoff!
|
||||||
|
([root filename attrs]
|
||||||
|
(put-handoff! root "new" filename
|
||||||
|
(merge {:from "sender"
|
||||||
|
:to "receiver"
|
||||||
|
:recipient "receiver"
|
||||||
|
:priority "50"
|
||||||
|
:type "git_handoff"
|
||||||
|
:task "task-one"
|
||||||
|
:commit "0123456789"
|
||||||
|
:body "merge_and_process sender 0123456789"}
|
||||||
|
attrs))))
|
||||||
|
|
||||||
|
(deftest swarm-handoff-validates-and-queues-git-handoffs
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
commit (init-repo! root)]
|
||||||
|
(setup-project! root)
|
||||||
|
(testing "git_handoff requires a task name"
|
||||||
|
(let [draft (fs/path root "tmp" "missing-task.handoff")]
|
||||||
|
(write-file draft (format "type: git_handoff\nto: receiver\npriority: 50\ncommit: %s\n" commit))
|
||||||
|
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "sender"} :ok? false}
|
||||||
|
(script "swarm_handoff.sh") (str draft))]
|
||||||
|
(is (= 2 (:exit result)))
|
||||||
|
(is (str/includes? (:err result) "Missing required header 'task'"))
|
||||||
|
(is (fs/exists? draft)))))
|
||||||
|
(testing "valid git_handoff writes task, canonical commit, and generated payload"
|
||||||
|
(let [draft (fs/path root "tmp" "valid.handoff")]
|
||||||
|
(write-file draft (format "type: git_handoff\nto: receiver\npriority: 50\ntask: task-1-cave-setup\ncommit: %s\n" commit))
|
||||||
|
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "sender"}}
|
||||||
|
(script "swarm_handoff.sh") (str draft))
|
||||||
|
queued (-> (:out result) str/trim (str/replace #"^HANDOFF QUEUED: " ""))
|
||||||
|
content (read-file queued)]
|
||||||
|
(is (str/includes? content "task: task-1-cave-setup\n"))
|
||||||
|
(is (str/includes? content (str "commit: " commit "\n")))
|
||||||
|
(is (str/includes? content (str "merge_and_process sender " commit)))
|
||||||
|
(is (fs/exists? queued))
|
||||||
|
(is (not (fs/exists? draft))))))))
|
||||||
|
|
||||||
|
(deftest ready-for-next-task-accepts-and-resumes-single-tasks
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(init-repo! root)
|
||||||
|
(setup-project! root {"receiver" "task"})
|
||||||
|
(testing "accepts one queued task and prints task name"
|
||||||
|
(make-queued-handoff! root "50_20260615T000001Z_000001_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000001Z_000001_from_sender"
|
||||||
|
:task "task-alpha"})
|
||||||
|
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||||
|
(script "ready_for_next.sh"))
|
||||||
|
out (:out result)
|
||||||
|
in-process (fs/path root ".swarmforge/handoffs/inbox/in_process/50_20260615T000001Z_000001_from_sender_to_receiver.handoff")]
|
||||||
|
(is (str/includes? out "TASK:"))
|
||||||
|
(is (str/includes? out "TASK_NAME: task-alpha"))
|
||||||
|
(is (fs/exists? in-process))
|
||||||
|
(is (some? (header in-process "dequeued_at")))))
|
||||||
|
(testing "returns existing in-process task before queued tasks"
|
||||||
|
(make-queued-handoff! root "40_20260615T000002Z_000002_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000002Z_000002_from_sender"
|
||||||
|
:priority "40"
|
||||||
|
:task "task-beta"})
|
||||||
|
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||||
|
(script "ready_for_next.sh"))]
|
||||||
|
(is (str/includes? (:out result) "task-alpha"))
|
||||||
|
(is (fs/exists? (fs/path root ".swarmforge/handoffs/inbox/new/40_20260615T000002Z_000002_from_sender_to_receiver.handoff")))))))
|
||||||
|
|
||||||
|
(deftest ready-for-next-batch-groups-equal-priority-handoffs
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(init-repo! root)
|
||||||
|
(setup-project! root {"receiver" "batch"})
|
||||||
|
(make-queued-handoff! root "10_20260615T000001Z_000001_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000001Z_000001_from_sender" :priority "10" :task "task-a"})
|
||||||
|
(make-queued-handoff! root "10_20260615T000002Z_000002_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000002Z_000002_from_sender" :priority "10" :task "task-b"})
|
||||||
|
(make-queued-handoff! root "20_20260615T000003Z_000003_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000003Z_000003_from_sender" :priority "20" :task "task-c"})
|
||||||
|
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||||
|
(script "ready_for_next.sh"))
|
||||||
|
out (:out result)
|
||||||
|
batch-dir (->> (str/split-lines out)
|
||||||
|
(filter #(str/starts-with? % "BATCH: "))
|
||||||
|
first
|
||||||
|
(#(subs % 7)))]
|
||||||
|
(is (str/includes? out "COUNT: 2"))
|
||||||
|
(is (str/includes? out "TASK_NAME: task-a"))
|
||||||
|
(is (str/includes? out "TASK_NAME: task-b"))
|
||||||
|
(is (not (str/includes? out "TASK_NAME: task-c")))
|
||||||
|
(is (= 2 (count (fs/glob batch-dir "*.handoff"))))
|
||||||
|
(is (fs/exists? (fs/path root ".swarmforge/handoffs/inbox/new/20_20260615T000003Z_000003_from_sender_to_receiver.handoff"))))))
|
||||||
|
|
||||||
|
(deftest done-with-current-task-completes-and-accepts-next-task
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(init-repo! root)
|
||||||
|
(setup-project! root {"receiver" "task"})
|
||||||
|
(put-handoff! root "in_process" "50_20260615T000001Z_000001_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000001Z_000001_from_sender"
|
||||||
|
:from "sender" :to "receiver" :recipient "receiver"
|
||||||
|
:priority "50" :type "git_handoff" :task "task-current"
|
||||||
|
:commit "0123456789"})
|
||||||
|
(make-queued-handoff! root "50_20260615T000002Z_000002_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000002Z_000002_from_sender"
|
||||||
|
:task "task-next"})
|
||||||
|
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||||
|
(script "done_with_current.sh"))
|
||||||
|
completed (fs/path root ".swarmforge/handoffs/inbox/completed/50_20260615T000001Z_000001_from_sender_to_receiver.handoff")
|
||||||
|
next-file (fs/path root ".swarmforge/handoffs/inbox/in_process/50_20260615T000002Z_000002_from_sender_to_receiver.handoff")]
|
||||||
|
(is (str/includes? (:out result) "COMPLETED:"))
|
||||||
|
(is (str/includes? (:out result) "TASK_NAME: task-next"))
|
||||||
|
(is (some? (header completed "completed_at")))
|
||||||
|
(is (some? (header next-file "dequeued_at"))))))
|
||||||
|
|
||||||
|
(deftest done-with-current-batch-completes-and-accepts-next-batch
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
batch (fs/path root ".swarmforge/handoffs/inbox/in_process/batch_20260615T000001Z_000001")]
|
||||||
|
(init-repo! root)
|
||||||
|
(setup-project! root {"receiver" "batch"})
|
||||||
|
(fs/create-dirs batch)
|
||||||
|
(write-file (fs/path batch "10_20260615T000001Z_000001_from_sender_to_receiver.handoff")
|
||||||
|
(handoff {:id "20260615T000001Z_000001_from_sender"
|
||||||
|
:from "sender" :to "receiver" :recipient "receiver"
|
||||||
|
:priority "10" :type "git_handoff" :task "task-a"
|
||||||
|
:commit "0123456789"}))
|
||||||
|
(write-file (fs/path batch "10_20260615T000002Z_000002_from_sender_to_receiver.handoff")
|
||||||
|
(handoff {:id "20260615T000002Z_000002_from_sender"
|
||||||
|
:from "sender" :to "receiver" :recipient "receiver"
|
||||||
|
:priority "10" :type "git_handoff" :task "task-b"
|
||||||
|
:commit "0123456789"}))
|
||||||
|
(make-queued-handoff! root "20_20260615T000003Z_000003_from_sender_to_receiver.handoff"
|
||||||
|
{:id "20260615T000003Z_000003_from_sender"
|
||||||
|
:priority "20"
|
||||||
|
:task "task-c"})
|
||||||
|
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||||
|
(script "done_with_current.sh"))
|
||||||
|
completed-batch (fs/path root ".swarmforge/handoffs/inbox/completed/batch_20260615T000001Z_000001")]
|
||||||
|
(is (str/includes? (:out result) "COMPLETED_BATCH:"))
|
||||||
|
(is (str/includes? (:out result) "TASK_NAME: task-c"))
|
||||||
|
(is (= 2 (count (fs/glob completed-batch "*.handoff"))))
|
||||||
|
(is (every? #(some? (header % "completed_at"))
|
||||||
|
(fs/glob completed-batch "*.handoff"))))))
|
||||||
|
|
||||||
|
(deftest stop-handoff-daemon-stops-running-process-and-removes-pid-file
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(init-repo! root)
|
||||||
|
(fs/create-dirs (fs/path root ".swarmforge/daemon"))
|
||||||
|
(write-file (fs/path root ".swarmforge/roles.tsv")
|
||||||
|
(str "coder\tmaster\t" root "\tsession\tCoder\tcodex\ttask\n"))
|
||||||
|
(write-file (fs/path root ".swarmforge/tmux-socket") "/tmp/fake.sock\n")
|
||||||
|
(run {:dir root :ok? false}
|
||||||
|
"sh" "-c"
|
||||||
|
(str "bb " (script "handoffd.bb") " " root " >/dev/null 2>&1 &"))
|
||||||
|
(Thread/sleep 1500)
|
||||||
|
(let [pid-file (fs/path root ".swarmforge/daemon/handoffd.pid")]
|
||||||
|
(is (fs/exists? pid-file))
|
||||||
|
(let [pid (str/trim (read-file pid-file))
|
||||||
|
stop (run {:dir root} (script "stop_handoff_daemon.bb") (str root))]
|
||||||
|
(is (= 0 (:exit stop)))
|
||||||
|
(Thread/sleep 300)
|
||||||
|
(is (not (fs/exists? pid-file)))
|
||||||
|
(is (not= 0 (:exit (run {:dir root :ok? false} "kill" "-0" pid))))))))
|
||||||
|
|
||||||
|
(deftest helpers-refuse-wrong-current-work-shape
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
batch (fs/path root ".swarmforge/handoffs/inbox/in_process/batch_20260615T000001Z_000001")]
|
||||||
|
(init-repo! root)
|
||||||
|
(setup-project! root {"receiver" "batch"})
|
||||||
|
(fs/create-dirs batch)
|
||||||
|
(write-file (fs/path batch "10_20260615T000001Z_000001_from_sender_to_receiver.handoff")
|
||||||
|
(handoff {:id "20260615T000001Z_000001_from_sender"
|
||||||
|
:from "sender" :to "receiver" :recipient "receiver"
|
||||||
|
:priority "10" :type "git_handoff" :task "task-a"
|
||||||
|
:commit "0123456789"}))
|
||||||
|
(testing "task helpers refuse an in-process batch"
|
||||||
|
(let [ready (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"} :ok? false}
|
||||||
|
(script "ready_for_next_task.sh"))
|
||||||
|
done (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"} :ok? false}
|
||||||
|
(script "done_with_current_task.sh"))]
|
||||||
|
(is (= 2 (:exit ready)))
|
||||||
|
(is (str/includes? (:err ready) "TASK_IN_PROCESS_IS_BATCH"))
|
||||||
|
(is (= 2 (:exit done)))
|
||||||
|
(is (str/includes? (:err done) "CURRENT_WORK_IS_BATCH"))))))
|
||||||
|
|
||||||
|
(defn -main [& _]
|
||||||
|
(let [{:keys [fail error]} (run-tests 'swarmforge.handoff-test)]
|
||||||
|
(System/exit (+ fail error))))
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
(ns swarmforge.script-test
|
||||||
|
(:require [babashka.fs :as fs]
|
||||||
|
[clojure.java.shell :as sh]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clojure.test :refer [deftest is testing]]))
|
||||||
|
|
||||||
|
(def repo-root (fs/cwd))
|
||||||
|
(def scripts-dir (fs/path repo-root "swarmforge" "scripts"))
|
||||||
|
|
||||||
|
(defn write-file [path text]
|
||||||
|
(fs/create-dirs (fs/parent path))
|
||||||
|
(spit (str path) text))
|
||||||
|
|
||||||
|
(defn run
|
||||||
|
[{:keys [dir env ok?]} & args]
|
||||||
|
(let [result (apply sh/sh (concat args [:dir (str dir)
|
||||||
|
:env (merge {"PATH" (System/getenv "PATH")
|
||||||
|
"GIT_CONFIG_NOSYSTEM" "1"}
|
||||||
|
env)]))]
|
||||||
|
(when (and (not (false? ok?)) (not= 0 (:exit result)))
|
||||||
|
(throw (ex-info (str "Command failed: " (str/join " " args))
|
||||||
|
(assoc result :args args))))
|
||||||
|
result))
|
||||||
|
|
||||||
|
(defn init-repo! [root]
|
||||||
|
(run {:dir root} "git" "init" "-q")
|
||||||
|
(run {:dir root} "git" "config" "user.email" "test@example.com")
|
||||||
|
(run {:dir root} "git" "config" "user.name" "Test User")
|
||||||
|
(write-file (fs/path root "README.md") "initial\n")
|
||||||
|
(run {:dir root} "git" "add" "README.md")
|
||||||
|
(run {:dir root} "git" "commit" "-q" "-m" "Initial commit"))
|
||||||
|
|
||||||
|
(defn tmp-dir []
|
||||||
|
(fs/create-temp-dir {:prefix "swarmforge-script-test."}))
|
||||||
|
|
||||||
|
(defn script [name]
|
||||||
|
(str (fs/path scripts-dir name)))
|
||||||
|
|
||||||
|
(deftest handoff-lib-parses-and-prints-handoff-files
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
handoff-file (fs/path root "task.handoff")]
|
||||||
|
(try
|
||||||
|
(write-file handoff-file
|
||||||
|
(str "id: 1\n"
|
||||||
|
"from: coder\n"
|
||||||
|
"to: cleaner\n"
|
||||||
|
"priority: 10\n"
|
||||||
|
"type: git_handoff\n"
|
||||||
|
"task: task-alpha\n"
|
||||||
|
"\n"
|
||||||
|
"merge_and_process coder abcdef1234\n"))
|
||||||
|
(let [header (run {:dir root} (script "handoff_lib.bb") "header-field" "task.handoff" "task")
|
||||||
|
body (run {:dir root} (script "handoff_lib.bb") "body" "task.handoff")
|
||||||
|
task (run {:dir root} (script "handoff_lib.bb") "print-task" "task.handoff")]
|
||||||
|
(is (str/includes? (:out header) "task-alpha"))
|
||||||
|
(is (str/includes? (:out body) "merge_and_process coder abcdef1234"))
|
||||||
|
(is (str/includes? (:out task) "TASK: task.handoff"))
|
||||||
|
(is (str/includes? (:out task) "FROM: coder"))
|
||||||
|
(is (str/includes? (:out task) "TASK_NAME: task-alpha")))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest handoff-lib-updates-headers-and-reads-role-state
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(init-repo! root)
|
||||||
|
(write-file (fs/path root ".swarmforge/roles.tsv")
|
||||||
|
(str "coder\tmaster\t" root "\tsession\tCoder\tcodex\ttask\n"
|
||||||
|
"cleaner\tcleaner\t" root "/.worktrees/cleaner\tsession\tCleaner\tcodex\tbatch\n"))
|
||||||
|
(write-file (fs/path root ".swarmforge/handoffs/inbox/new/item.handoff")
|
||||||
|
(str "id: 1\n"
|
||||||
|
"from: coder\n"
|
||||||
|
"to: cleaner\n"
|
||||||
|
"priority: 20\n"
|
||||||
|
"type: note\n"
|
||||||
|
"\n"
|
||||||
|
"payload\n"))
|
||||||
|
(run {:dir root} (script "handoff_lib.bb") "role-known" "cleaner")
|
||||||
|
(run {:dir root} (script "handoff_lib.bb") "set-header" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at" "2026-06-16T00:00:00Z")
|
||||||
|
(let [mode (run {:dir root} (script "handoff_lib.bb") "role-receive-mode" "cleaner")
|
||||||
|
worktree (run {:dir root} (script "handoff_lib.bb") "role-worktree-name" "cleaner")
|
||||||
|
dequeued (run {:dir root} (script "handoff_lib.bb") "header-field" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at")
|
||||||
|
seq-1 (run {:dir root} (script "handoff_lib.bb") "next-sequence")
|
||||||
|
seq-2 (run {:dir root} (script "handoff_lib.bb") "next-sequence")]
|
||||||
|
(is (str/includes? (:out mode) "batch"))
|
||||||
|
(is (str/includes? (:out worktree) "cleaner"))
|
||||||
|
(is (str/includes? (:out dequeued) "2026-06-16T00:00:00Z"))
|
||||||
|
(is (str/includes? (:out seq-1) "000001"))
|
||||||
|
(is (str/includes? (:out seq-2) "000002")))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest swarmforge-launcher-parses-config-and-writes-state-files
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||||
|
"Read articles.\n")
|
||||||
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||||
|
(str "# comment\n"
|
||||||
|
"window coder codex master\n"
|
||||||
|
"window cleaner codex cleaner batch\n"))
|
||||||
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||||
|
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
|
||||||
|
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
|
||||||
|
(is (str/includes? (:out result) "coder Coder"))
|
||||||
|
(is (str/includes? (:out result) "cleaner Cleaner"))
|
||||||
|
(is (str/includes? (:out result) "cleaner batch"))
|
||||||
|
(is (str/includes? (:out result) "swarmforge-coder"))
|
||||||
|
(is (str/includes? (:out result) "swarmforge-cleaner"))
|
||||||
|
(is (fs/exists? (fs/path root ".swarmforge/tmux-socket"))))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest swarmforge-uses-portable-tmux-socket-dir
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||||
|
"Read articles.\n")
|
||||||
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||||
|
"window coder codex master\n")
|
||||||
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||||
|
(run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))
|
||||||
|
(let [socket-path (str/trim (slurp (str (fs/path root ".swarmforge/tmux-socket"))))]
|
||||||
|
(is (str/starts-with? socket-path "/tmp/swarmforge-"))
|
||||||
|
(is (not (str/starts-with? socket-path "/private/tmp/"))))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest swarmforge-launcher-rejects-invalid-config
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||||
|
"Read articles.\n")
|
||||||
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||||
|
(str "window coder codex master\n"
|
||||||
|
"window coder codex other\n"))
|
||||||
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||||
|
(let [result (run {:dir root :ok? false} (script "swarmforge.bb") "--test-parse" (str root))]
|
||||||
|
(is (= 1 (:exit result)))
|
||||||
|
(is (str/includes? (:err result) "Duplicate role 'coder'")))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest swarmforge-terminal-bridge-preserves-adapter-globals
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(write-file (fs/path root "swarmforge/scripts/swarm-terminal-adapter.sh")
|
||||||
|
(str "load_terminal_backend() {\n"
|
||||||
|
" source \"$SCRIPT_DIR/terminal-adapters/$1.sh\"\n"
|
||||||
|
"}\n"))
|
||||||
|
(write-file (fs/path root "swarmforge/scripts/terminal-adapters/probe.sh")
|
||||||
|
(str "terminal_open_session() {\n"
|
||||||
|
" printf '%s\\n' \"$WORKING_DIR|$TMUX_SOCKET|$1|$2|$3\"\n"
|
||||||
|
"}\n"))
|
||||||
|
(let [result (run {:dir root}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-terminal-bridge"
|
||||||
|
(str root)
|
||||||
|
"probe")]
|
||||||
|
(is (str/includes? (:out result) (str root "|")))
|
||||||
|
(is (str/includes? (:out result) "|swarmforge-specifier|SwarmForge Specifier|"))
|
||||||
|
(is (not (str/includes? (:out result) "cd ''")))
|
||||||
|
(is (not (str/includes? (:out result) "-S ''"))))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest swarmforge-agent-start-delay-is-configurable
|
||||||
|
(let [default-result (run {:dir repo-root}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-agent-start-delay")
|
||||||
|
configured-result (run {:dir repo-root
|
||||||
|
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "2750"}}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-agent-start-delay")
|
||||||
|
invalid-result (run {:dir repo-root
|
||||||
|
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "fast"}}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-agent-start-delay")]
|
||||||
|
(is (= "1500" (str/trim (:out default-result))))
|
||||||
|
(is (= "2750" (str/trim (:out configured-result))))
|
||||||
|
(is (= "1500" (str/trim (:out invalid-result))))))
|
||||||
|
|
||||||
|
(deftest swarmforge-sleep-prevention-can-be-disabled
|
||||||
|
(let [result (run {:dir repo-root
|
||||||
|
:env {"SWARMFORGE_PREVENT_SLEEP" "0"}}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-sleep-inhibitor-prefix")]
|
||||||
|
(is (= "" (str/trim (:out result))))))
|
||||||
|
|
||||||
|
(deftest swarmforge-launcher-parses-extra-cli-args
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||||
|
"Read articles.\n")
|
||||||
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||||
|
(str "window coder copilot master --yolo\n"
|
||||||
|
"window cleaner copilot cleaner batch --allow-all-tools\n"))
|
||||||
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||||
|
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
|
||||||
|
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
|
||||||
|
(is (str/includes? (:out result) "coder Coder"))
|
||||||
|
(is (str/includes? (:out result) "task --yolo"))
|
||||||
|
(is (str/includes? (:out result) "batch --allow-all-tools")))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest copilot-launch-command-passes-extra-cli-args
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(let [result (run {:dir root}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-launch-command"
|
||||||
|
(str root)
|
||||||
|
"copilot"
|
||||||
|
"--yolo")
|
||||||
|
command (:out result)]
|
||||||
|
(is (str/includes? command "copilot -C "))
|
||||||
|
(is (re-find #"--name 'SwarmForge Coder' --yolo -i" command)))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest grok-launch-command-passes-initial-prompt
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(let [result (run {:dir root}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-launch-command"
|
||||||
|
(str root)
|
||||||
|
"grok")
|
||||||
|
command (:out result)]
|
||||||
|
(is (str/includes? command "grok --cwd "))
|
||||||
|
(is (str/includes? command "--permission-mode acceptEdits"))
|
||||||
|
(is (str/includes? command "--rules \"$(cat "))
|
||||||
|
(is (str/includes? command "--verbatim \"$(cat "))
|
||||||
|
(is (str/includes? command ".swarmforge/prompts/coder.md"))
|
||||||
|
(is (fs/exists? (fs/path root ".swarmforge/prompts/coder.md"))))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest grok-launch-command-uses-bypass-permissions-with-always-approve
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(let [result (run {:dir root}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-launch-command"
|
||||||
|
(str root)
|
||||||
|
"grok"
|
||||||
|
"--always-approve")
|
||||||
|
command (:out result)]
|
||||||
|
(is (str/includes? command "--permission-mode bypassPermissions"))
|
||||||
|
(is (str/includes? command "--always-approve"))
|
||||||
|
(is (not (str/includes? command "--permission-mode acceptEdits"))))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest window-watchdog-rewrites-window-state-and-id-list
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
state-file (fs/path root "windows.tsv")
|
||||||
|
ids-file (fs/path root "window-ids")]
|
||||||
|
(try
|
||||||
|
(write-file state-file
|
||||||
|
(str "1\told-a\tswarmforge-coder\tSwarmForge Coder\n"
|
||||||
|
"2\told-b\tswarmforge-cleaner\tSwarmForge Cleaner\n"))
|
||||||
|
(write-file ids-file "old-a\nold-b\n")
|
||||||
|
(run {:dir root} (script "swarm-window-watchdog.bb") "--rewrite-window-id" "windows.tsv" "window-ids" "2" "new-b")
|
||||||
|
(let [state (slurp (str state-file))
|
||||||
|
ids (slurp (str ids-file))]
|
||||||
|
(is (str/includes? state "1\told-a\tswarmforge-coder\tSwarmForge Coder"))
|
||||||
|
(is (str/includes? state "2\tnew-b\tswarmforge-cleaner\tSwarmForge Cleaner"))
|
||||||
|
(is (= "old-a\nnew-b\n" ids)))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest swarmforge-detects-nonzero-pane-base-index
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
sock (str root "/test.sock")
|
||||||
|
conf (fs/path root "tmux.conf")]
|
||||||
|
(try
|
||||||
|
(write-file conf "set -g base-index 1\nset -g pane-base-index 1\n")
|
||||||
|
(run {:dir root} "tmux" "-S" sock "-f" (str conf) "new-session" "-d" "-s" "probe" "sleep" "120")
|
||||||
|
(let [result (run {:dir root}
|
||||||
|
(script "swarmforge.bb")
|
||||||
|
"--test-tmux-base-indexes"
|
||||||
|
sock)]
|
||||||
|
(is (= "1 1" (str/trim (:out result)))))
|
||||||
|
(finally
|
||||||
|
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest swarm-cleanup-tolerates-missing-runtime-state
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
ids-file (fs/path root ".swarmforge/window-ids")]
|
||||||
|
(try
|
||||||
|
(write-file ids-file "window-a\nwindow-b\n")
|
||||||
|
(let [result (run {:dir root
|
||||||
|
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
||||||
|
(str (fs/path scripts-dir "swarm-cleanup.sh"))
|
||||||
|
"/tmp/nonexistent.sock"
|
||||||
|
(str ids-file))]
|
||||||
|
(is (= 0 (:exit result)))
|
||||||
|
(is (= "" (:err result))))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(defn close-swarm []
|
||||||
|
(str (fs/path repo-root "close-swarm")))
|
||||||
|
|
||||||
|
(deftest close-swarm-reports-when-no-swarm-state
|
||||||
|
(let [root (tmp-dir)]
|
||||||
|
(try
|
||||||
|
(let [result (run {:dir root :ok? false
|
||||||
|
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
||||||
|
(close-swarm)
|
||||||
|
(str root))]
|
||||||
|
(is (not= 0 (:exit result)))
|
||||||
|
(is (str/includes? (str (:err result) (:out result)) "No SwarmForge swarm")))
|
||||||
|
(finally
|
||||||
|
(fs/delete-tree root)))))
|
||||||
|
|
||||||
|
(deftest close-swarm-kills-tmux-sessions-and-stops-daemon
|
||||||
|
(let [root (tmp-dir)
|
||||||
|
sock (str (fs/path root "swarm.sock"))
|
||||||
|
pid-file (fs/path root ".swarmforge/daemon/handoffd.pid")
|
||||||
|
daemon (.start (java.lang.ProcessBuilder. ["sleep" "120"]))
|
||||||
|
pid (str (.pid daemon))]
|
||||||
|
(try
|
||||||
|
(write-file (fs/path root ".swarmforge/tmux-socket") (str sock "\n"))
|
||||||
|
(write-file (fs/path root ".swarmforge/sessions.tsv")
|
||||||
|
(str "1\tcoder\tswarmforge-coder\tCoder\tcodex\n"
|
||||||
|
"2\tcleaner\tswarmforge-cleaner\tCleaner\tcodex\n"))
|
||||||
|
(write-file (fs/path root ".swarmforge/window-ids") "win-a\nwin-b\n")
|
||||||
|
(write-file pid-file (str pid "\n"))
|
||||||
|
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-coder" "sleep" "120")
|
||||||
|
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-cleaner" "sleep" "120")
|
||||||
|
(let [result (run {:dir root
|
||||||
|
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
||||||
|
(close-swarm)
|
||||||
|
(str root))]
|
||||||
|
(is (= 0 (:exit result)))
|
||||||
|
(is (not= 0 (:exit (run {:dir root :ok? false}
|
||||||
|
"tmux" "-S" sock "has-session" "-t" "swarmforge-coder"))))
|
||||||
|
(is (not= 0 (:exit (run {:dir root :ok? false}
|
||||||
|
"tmux" "-S" sock "has-session" "-t" "swarmforge-cleaner"))))
|
||||||
|
(is (not (fs/exists? pid-file)))
|
||||||
|
(is (false? (.isAlive daemon))))
|
||||||
|
(finally
|
||||||
|
(when (.isAlive daemon)
|
||||||
|
(.destroyForcibly daemon))
|
||||||
|
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
|
||||||
|
(fs/delete-tree root)))))
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# sf-queue — estado de las colas de handoff de todos los roles del swarm.
|
||||||
|
# Uso: sf-queue [directorio-del-proyecto] (por defecto: directorio actual)
|
||||||
|
# Muestra: new (pendientes) · proc (en proceso, incluye batches) · done (completados)
|
||||||
|
# outbox (por enviar) · sent (enviados) + nombres de tarea.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="${1:-$(pwd)}"
|
||||||
|
cd "$ROOT"
|
||||||
|
TSV=".swarmforge/roles.tsv"
|
||||||
|
[ -f "$TSV" ] || { echo "No hay swarm en $ROOT (falta $TSV)" >&2; exit 1; }
|
||||||
|
|
||||||
|
count() { # count <globs...> → nº de ficheros .handoff (también dentro de batch_*/)
|
||||||
|
local n=0 f
|
||||||
|
for f in "$@"; do [ -f "$f" ] && n=$((n+1)); done
|
||||||
|
echo "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r role wtname wtpath session display agent mode; do
|
||||||
|
H="$wtpath/.swarmforge/handoffs"
|
||||||
|
[ -d "$H" ] || continue
|
||||||
|
new=$(count "$H"/inbox/new/*.handoff)
|
||||||
|
proc=$(count "$H"/inbox/in_process/*.handoff "$H"/inbox/in_process/batch_*/*.handoff)
|
||||||
|
donec=$(count "$H"/inbox/completed/*.handoff "$H"/inbox/completed/batch_*/*.handoff)
|
||||||
|
out=$(count "$H"/outbox/*.handoff)
|
||||||
|
sent=$(count "$H"/sent/*.handoff)
|
||||||
|
printf "== %-10s new=%-2s proc=%-2s done=%-2s outbox=%-2s sent=%-2s\n" \
|
||||||
|
"$role" "$new" "$proc" "$donec" "$out" "$sent"
|
||||||
|
for f in "$H"/inbox/new/*.handoff; do [ -f "$f" ] && echo " [pendiente] $(sed -n 's/^task: //p' "$f")"; done
|
||||||
|
for f in "$H"/inbox/in_process/*.handoff; do [ -f "$f" ] && echo " [en proceso] $(sed -n 's/^task: //p' "$f")"; done
|
||||||
|
for d in "$H"/inbox/in_process/batch_*; do
|
||||||
|
[ -d "$d" ] && for f in "$d"/*.handoff; do [ -f "$f" ] && echo " [batch] $(sed -n 's/^task: //p' "$f")"; done
|
||||||
|
done
|
||||||
|
for f in "$H"/outbox/*.handoff; do [ -f "$f" ] && echo " [outbox] $(sed -n 's/^task: //p' "$f")"; done
|
||||||
|
done < "$TSV"
|
||||||
|
exit 0
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# sf-tokens — consumo de tokens por rol (desde las sesiones de pi).
|
||||||
|
# Uso: sf-tokens [directorio-del-proyecto] (por defecto: directorio actual)
|
||||||
|
# Suma usage de todas las sesiones pi de cada worktree (cumulativo por rol).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="${1:-$(pwd)}"
|
||||||
|
ROOT="$(cd "$ROOT" && pwd)"
|
||||||
|
TSV="$ROOT/.swarmforge/roles.tsv"
|
||||||
|
[ -f "$TSV" ] || { echo "No hay swarm en $ROOT (falta $TSV)" >&2; exit 1; }
|
||||||
|
SESS_DIR="${PI_SESSIONS_DIR:-$HOME/.pi/agent/sessions}"
|
||||||
|
|
||||||
|
# El dir de sesión de pi se deriva del path del worktree:
|
||||||
|
# /home/pol/Work/code/saas-prototype/.worktrees/coder → --home-pol-Work-code-saas-prototype-.worktrees-coder--
|
||||||
|
session_dir_for_path() {
|
||||||
|
local p="$1"
|
||||||
|
p="${p%/}" # quitar / final
|
||||||
|
p="${p%/\.}" # quitar '/.' (path de master en roles.tsv)
|
||||||
|
p="${p#/}" # quitar / inicial
|
||||||
|
p="${p//\//-}" # / → -
|
||||||
|
echo "--${p}--"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt() { printf "%'d" "$1"; }
|
||||||
|
|
||||||
|
total_in=0; total_out=0; total_cache=0; total_all=0
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r role wtname wtpath session display agent mode; do
|
||||||
|
[ -n "$role" ] || continue
|
||||||
|
sdir="$(session_dir_for_path "$wtpath")"
|
||||||
|
dir="$SESS_DIR/$sdir"
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
printf "%-10s sin sesión pi (%s)\n" "$role" "$sdir"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Sumar usage de todos los mensajes de todas las sesiones del rol
|
||||||
|
read -r i o cr cw all <<< "$(awk -F'"' '
|
||||||
|
/"usage":/ {
|
||||||
|
line=$0
|
||||||
|
if (match(line, /"input":([0-9]+)/)) in_t += substr(line, RSTART+8, RLENGTH-8)
|
||||||
|
if (match(line, /"output":([0-9]+)/)) out_t += substr(line, RSTART+9, RLENGTH-9)
|
||||||
|
if (match(line, /"cacheRead":([0-9]+)/)) cr_t += substr(line, RSTART+12, RLENGTH-12)
|
||||||
|
if (match(line, /"cacheWrite":([0-9]+)/)) cw_t += substr(line, RSTART+13, RLENGTH-13)
|
||||||
|
}
|
||||||
|
END { printf "%d %d %d %d %d\n", in_t, out_t, cr_t, cw_t, in_t+out_t+cr_t+cw_t }
|
||||||
|
' "$dir"/*.jsonl 2>/dev/null)"
|
||||||
|
if [ -n "${all:-}" ]; then
|
||||||
|
printf "%-10s input=%-12s output=%-11s cache=%-11s TOTAL=%s\n" \
|
||||||
|
"$role" "$(fmt "$i")" "$(fmt "$o")" "$(fmt $((cr+cw)))" "$(fmt "$all")"
|
||||||
|
total_in=$((total_in+i)); total_out=$((total_out+o)); total_cache=$((total_cache+cr+cw)); total_all=$((total_all+all))
|
||||||
|
else
|
||||||
|
printf "%-10s sin uso registrado\n" "$role"
|
||||||
|
fi
|
||||||
|
done < "$TSV"
|
||||||
|
|
||||||
|
echo "------------------------------------------"
|
||||||
|
printf "TOTAL input=%-12s output=%-11s cache=%-11s TOTAL=%s\n" \
|
||||||
|
"$(fmt "$total_in")" "$(fmt "$total_out")" "$(fmt "$total_cache")" "$(fmt "$total_all")"
|
||||||
Reference in New Issue
Block a user