@nbardy/oompa 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +39,7 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
39
39
 
40
40
  - **Different worker types** — small models for fast execution, big models for planning
41
41
  - **Separate review model** — use a smart model to check work before merging
42
- - **Mixed harnesses** — combine Claude and Codex workers in one swarm
42
+ - **Mixed harnesses** — combine Claude, Codex, and Opencode workers in one swarm
43
43
  - **Self-directed tasks** — workers create and claim tasks from shared folders
44
44
 
45
45
  ### Architecture
@@ -84,20 +84,22 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
84
84
  {
85
85
  "workers": [
86
86
  {"model": "claude:opus", "prompt": ["config/prompts/planner.md"], "iterations": 5, "count": 1},
87
- {"model": "codex:gpt-5.3-codex:medium", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 3, "can_plan": false}
87
+ {"model": "codex:gpt-5.3-codex:medium", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 2, "can_plan": false},
88
+ {"model": "opencode:openai/gpt-5", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 1, "can_plan": false}
88
89
  ]
89
90
  }
90
91
  ```
91
92
 
92
93
  This spawns:
93
94
  - **1 planner** (opus) — reads spec, explores codebase, creates/refines tasks
94
- - **3 executors** (gpt-5.3-codex, medium reasoning) — claims and executes tasks fast
95
+ - **2 codex executors** (gpt-5.3-codex, medium reasoning) — claims and executes tasks fast
96
+ - **1 opencode executor** (openai/gpt-5) — same task loop via `opencode run`
95
97
 
96
98
  #### Worker fields
97
99
 
98
100
  | Field | Required | Description |
99
101
  |-------|----------|-------------|
100
- | `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`) |
102
+ | `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`, `opencode:openai/gpt-5`) |
101
103
  | `prompt` | no | String or array of paths — concatenated into one prompt |
102
104
  | `iterations` | no | Max iterations per worker (default: 10) |
103
105
  | `count` | no | Number of workers with this config (default: 1) |
@@ -111,7 +113,7 @@ This spawns:
111
113
  {
112
114
  "workers": [
113
115
  {"model": "claude:opus-4.5", "prompt": ["prompts/base.md", "prompts/architect.md"], "count": 1},
114
- {"model": "codex:codex-5.2-mini", "prompt": ["prompts/base.md", "prompts/frontend.md"], "count": 2},
116
+ {"model": "opencode:openai/gpt-5-mini", "prompt": ["prompts/base.md", "prompts/frontend.md"], "count": 2},
115
117
  {"model": "codex:codex-5.2-mini", "prompt": ["prompts/base.md", "prompts/backend.md"], "count": 2}
116
118
  ]
117
119
  }
@@ -202,21 +204,28 @@ oompa help # Show all commands
202
204
 
203
205
  `./swarm.bb ...` works the same when running from a source checkout.
204
206
 
207
+ ## Opencode Harness
208
+
209
+ `opencode` workers use one-shot `opencode run --format json` calls with the same worker prompt tagging:
210
+
211
+ - First prompt line still starts with `[oompa:<swarmId>:<workerId>]`
212
+ - `-m/--model` is passed when a worker model is configured
213
+ - First iteration starts without `--session`; the worker captures `sessionID` from that exact run output
214
+ - On resumed iterations, workers pass `-s/--session <captured-id> --continue`
215
+ - Oompa does not call `opencode session list` to guess a "latest" session
216
+ - Worker completion markers (`COMPLETE_AND_READY_FOR_MERGE`, `__DONE__`) are evaluated from extracted text events, preserving existing done/merge behavior
217
+ - Optional attach mode: set `OOMPA_OPENCODE_ATTACH` (or `OPENCODE_ATTACH`) to add `--attach <url>`
218
+
205
219
  ## Worker Conversation Persistence
206
220
 
207
- If `codex-persist` is available, each worker writes its prompt/response messages
208
- to a per-worker session file for external UIs (for example worker panes in
209
- `claude-web-view`).
221
+ Workers rely on each CLI's native session persistence (no custom mirror writer):
210
222
 
211
- - Session ID: random lowercase UUID per iteration (one file per iteration)
212
- - First user message tag format: `[oompa:<swarmId>:<workerId>]`
213
- - CWD passed to `codex-persist` is the worker worktree absolute path
214
- - Codex workers use `codex-persist` writes; Claude workers use native `--session-id`
223
+ - Codex: native rollouts under `~/.codex/sessions/YYYY/MM/DD/*.jsonl`
224
+ - Claude: native project sessions under `~/.claude/projects/*/*.jsonl`
225
+ - Opencode: native session store managed by `opencode`
215
226
 
216
- Resolution order for the CLI command:
217
- 1. `CODEX_PERSIST_BIN` (if set)
218
- 2. `codex-persist` on `PATH`
219
- 3. `node ~/git/codex-persist/dist/cli.js`
227
+ Oompa still tags the first prompt line with `[oompa:<swarmId>:<workerId>]`
228
+ so downstream UIs can identify and group worker conversations.
220
229
 
221
230
  ## Requirements
222
231
 
@@ -226,6 +235,7 @@ Resolution order for the CLI command:
226
235
  - One of:
227
236
  - [Claude CLI](https://github.com/anthropics/claude-cli)
228
237
  - [Codex CLI](https://github.com/openai/codex)
238
+ - [Opencode CLI](https://github.com/sst/opencode)
229
239
 
230
240
  ## License
231
241
 
@@ -12,7 +12,8 @@
12
12
 
13
13
  Supported Backends:
14
14
  :codex - OpenAI Codex CLI (codex exec)
15
- :claude - Anthropic Claude CLI (claude -p)"
15
+ :claude - Anthropic Claude CLI (claude -p)
16
+ :opencode - opencode CLI (opencode run)"
16
17
  (:require [agentnet.schema :as schema]
17
18
  [babashka.process :as process]
18
19
  [clojure.java.io :as io]
@@ -71,6 +72,22 @@
71
72
  model (into ["--model" model])
72
73
  true (conj "--dangerously-skip-permissions")))
73
74
 
75
+ (defn- opencode-attach-url
76
+ "Optional opencode server URL for run --attach mode."
77
+ []
78
+ (let [url (or (System/getenv "OOMPA_OPENCODE_ATTACH")
79
+ (System/getenv "OPENCODE_ATTACH"))]
80
+ (when (and url (not (str/blank? url)))
81
+ url)))
82
+
83
+ (defmethod build-command :opencode
84
+ [_ {:keys [model]} prompt cwd]
85
+ (let [attach (opencode-attach-url)]
86
+ (cond-> ["opencode" "run"]
87
+ model (into ["-m" model])
88
+ attach (into ["--attach" attach])
89
+ true (conj prompt))))
90
+
74
91
  (defmethod build-command :default
75
92
  [agent-type _ _ _]
76
93
  (throw (ex-info (str "Unknown agent type: " agent-type)
@@ -269,6 +286,7 @@
269
286
  (let [cmd (case agent-type
270
287
  :codex ["codex" "--version"]
271
288
  :claude ["claude" "--version"]
289
+ :opencode ["opencode" "--version"]
272
290
  ["echo" "unknown"])]
273
291
  (try
274
292
  (let [{:keys [exit]} (process/sh cmd {:out :string :err :string})]
@@ -5,7 +5,7 @@
5
5
  ./swarm.bb run # Run all tasks once
6
6
  ./swarm.bb run --workers 4 # With 4 parallel workers
7
7
  ./swarm.bb loop 20 --harness claude # 20 iterations with Claude
8
- ./swarm.bb loop --workers claude:5 codex:4 --iterations 20 # Mixed harnesses
8
+ ./swarm.bb loop --workers claude:5 opencode:2 --iterations 20 # Mixed harnesses
9
9
  ./swarm.bb swarm oompa.json # Multi-model from config
10
10
  ./swarm.bb prompt \"...\" # Ad-hoc task
11
11
  ./swarm.bb status # Show last run
@@ -16,6 +16,7 @@
16
16
  [agentnet.worker :as worker]
17
17
  [agentnet.tasks :as tasks]
18
18
  [agentnet.agent :as agent]
19
+ [agentnet.runs :as runs]
19
20
  [babashka.process :as process]
20
21
  [clojure.string :as str]
21
22
  [clojure.java.io :as io]
@@ -30,20 +31,22 @@
30
31
  (Integer/parseInt s)
31
32
  (catch Exception _ default)))
32
33
 
34
+ (def ^:private harnesses #{:codex :claude :opencode})
35
+
33
36
  (defn- make-swarm-id
34
37
  "Generate a short run-level swarm ID."
35
38
  []
36
39
  (subs (str (java.util.UUID/randomUUID)) 0 8))
37
40
 
38
41
  (defn- parse-worker-spec
39
- "Parse 'harness:count' into {:harness :claude, :count 5}.
42
+ "Parse 'harness:count' into {:harness :opencode, :count 5}.
40
43
  Throws on invalid format."
41
44
  [s]
42
45
  (let [[harness count-str] (str/split s #":" 2)
43
46
  h (keyword harness)
44
47
  cnt (parse-int count-str 0)]
45
- (when-not (#{:codex :claude} h)
46
- (throw (ex-info (str "Unknown harness in worker spec: " s ". Use 'codex:N' or 'claude:N'") {})))
48
+ (when-not (harnesses h)
49
+ (throw (ex-info (str "Unknown harness in worker spec: " s ". Use 'codex:N', 'claude:N', or 'opencode:N'") {})))
47
50
  (when (zero? cnt)
48
51
  (throw (ex-info (str "Invalid count in worker spec: " s ". Use format 'harness:count'") {})))
49
52
  {:harness h :count cnt}))
@@ -80,7 +83,7 @@
80
83
  (= arg "--workers")
81
84
  (let [next-arg (second remaining)]
82
85
  (if (worker-spec? next-arg)
83
- ;; Collect all worker specs: --workers claude:5 codex:4
86
+ ;; Collect all worker specs: --workers claude:5 opencode:2
84
87
  (let [[specs rest] (collect-worker-specs (next remaining))]
85
88
  (recur (assoc opts :worker-specs specs) rest))
86
89
  ;; Simple count: --workers 4
@@ -93,8 +96,8 @@
93
96
 
94
97
  (= arg "--harness")
95
98
  (let [h (keyword (second remaining))]
96
- (when-not (#{:codex :claude} h)
97
- (throw (ex-info (str "Unknown harness: " (second remaining) ". Use 'codex' or 'claude'") {})))
99
+ (when-not (harnesses h)
100
+ (throw (ex-info (str "Unknown harness: " (second remaining) ". Use 'codex', 'claude', or 'opencode'") {})))
98
101
  (recur (assoc opts :harness h)
99
102
  (nnext remaining)))
100
103
 
@@ -135,6 +138,21 @@
135
138
 
136
139
  (declare cmd-swarm parse-model-string)
137
140
 
141
+ (defn- check-git-clean!
142
+ "Abort if git working tree is dirty. Dirty index causes merge conflicts
143
+ and wasted worker iterations."
144
+ []
145
+ (let [result (process/sh ["git" "status" "--porcelain"]
146
+ {:out :string :err :string})
147
+ output (str/trim (:out result))]
148
+ (when (and (zero? (:exit result)) (not (str/blank? output)))
149
+ (println "ERROR: Git working tree is dirty. Resolve before running swarm.")
150
+ (println)
151
+ (println output)
152
+ (println)
153
+ (println "Run 'git stash' or 'git commit' first.")
154
+ (System/exit 1))))
155
+
138
156
  (defn- probe-model
139
157
  "Send 'say ok' to a model via its harness CLI. Returns true if model responds.
140
158
  Claude hangs without /dev/null stdin when spawned from bb."
@@ -142,7 +160,8 @@
142
160
  (try
143
161
  (let [cmd (case harness
144
162
  :claude ["claude" "--model" model "-p" "[oompa:probe] say ok" "--max-turns" "1"]
145
- :codex ["codex" "exec" "--dangerously-bypass-approvals-and-sandbox" "--skip-git-repo-check" "--model" model "--" "[oompa:probe] say ok"])
163
+ :codex ["codex" "exec" "--dangerously-bypass-approvals-and-sandbox" "--skip-git-repo-check" "--model" model "--" "[oompa:probe] say ok"]
164
+ :opencode ["opencode" "run" "-m" model "[oompa:probe] say ok"])
146
165
  null-in (io/input-stream (io/file "/dev/null"))
147
166
  proc (process/process cmd {:out :string :err :string :in null-in})
148
167
  result (deref proc 30000 :timeout)]
@@ -187,7 +206,7 @@
187
206
  (cmd-swarm opts (or (seq args) ["oompa.json"]))
188
207
  (let [swarm-id (make-swarm-id)]
189
208
  (if-let [specs (:worker-specs opts)]
190
- ;; Mixed worker specs: --workers claude:5 codex:4
209
+ ;; Mixed worker specs: --workers claude:5 opencode:2
191
210
  (let [workers (mapcat
192
211
  (fn [spec]
193
212
  (let [{:keys [harness count]} spec]
@@ -219,7 +238,7 @@
219
238
  (:iterations opts)
220
239
  20)]
221
240
  (if-let [specs (:worker-specs opts)]
222
- ;; Mixed worker specs: --workers claude:5 codex:4
241
+ ;; Mixed worker specs: --workers claude:5 opencode:2
223
242
  (let [workers (mapcat
224
243
  (fn [spec]
225
244
  (let [{:keys [harness count]} spec]
@@ -265,25 +284,65 @@
265
284
  (orchestrator/run-once! opts))))
266
285
 
267
286
  (defn cmd-status
268
- "Show status of last run"
287
+ "Show status of last run — reads structured runs/{swarm-id}/ data."
269
288
  [opts args]
270
- (let [runs-dir (io/file "runs")
271
- files (when (.exists runs-dir)
272
- (->> (.listFiles runs-dir)
273
- (filter #(.isFile %))
274
- (sort-by #(.lastModified %) >)))]
275
- (if-let [latest (first files)]
276
- (do
277
- (println (format "Latest run: %s" (.getName latest)))
289
+ (let [run-ids (runs/list-runs)]
290
+ (if (seq run-ids)
291
+ (let [swarm-id (or (first args) (first run-ids))
292
+ run-log (runs/read-run-log swarm-id)
293
+ summary (runs/read-summary swarm-id)
294
+ reviews (runs/list-reviews swarm-id)]
295
+ (println (format "Swarm: %s" swarm-id))
296
+ (when run-log
297
+ (println (format " Started: %s" (:started-at run-log)))
298
+ (println (format " Config: %s" (or (:config-file run-log) "N/A")))
299
+ (println (format " Workers: %d" (count (:workers run-log)))))
278
300
  (println)
279
- (with-open [r (io/reader latest)]
280
- (let [entries (mapv #(json/parse-string % true) (line-seq r))
281
- by-status (group-by :status entries)]
282
- (doseq [[status tasks] (sort-by first by-status)]
283
- (println (format "%s: %d" (name status) (count tasks))))
301
+ (if summary
302
+ (do
303
+ (println (format "Summary (finished %s):" (:finished-at summary)))
304
+ (println (format " Total completed: %d/%d iterations"
305
+ (:total-completed summary) (:total-iterations summary)))
306
+ (println (format " Status counts: %s" (pr-str (:status-counts summary))))
284
307
  (println)
285
- (println (format "Total: %d tasks" (count entries))))))
286
- (println "No runs found."))))
308
+ (println "Per-worker:")
309
+ (doseq [w (:workers summary)]
310
+ (println (format " [%s] %s:%s — %s, %d completed, %d merges, %d rejections, %d errors, %d review rounds"
311
+ (:id w)
312
+ (or (:harness w) "unknown")
313
+ (or (:model w) "default")
314
+ (or (:status w) "unknown")
315
+ (or (:completed w) 0)
316
+ (or (:merges w) 0)
317
+ (or (:rejections w) 0)
318
+ (or (:errors w) 0)
319
+ (or (:review-rounds-total w) 0)))))
320
+ (println " (still running — no summary yet)"))
321
+ (when (seq reviews)
322
+ (println)
323
+ (println (format "Reviews: %d total" (count reviews)))
324
+ (doseq [r reviews]
325
+ (println (format " %s-i%d-r%d: %s"
326
+ (:worker-id r) (:iteration r) (:round r)
327
+ (:verdict r))))))
328
+ ;; Fall back to legacy JSONL format
329
+ (let [runs-dir (io/file "runs")
330
+ files (when (.exists runs-dir)
331
+ (->> (.listFiles runs-dir)
332
+ (filter #(.isFile %))
333
+ (sort-by #(.lastModified %) >)))]
334
+ (if-let [latest (first files)]
335
+ (do
336
+ (println (format "Latest run (legacy): %s" (.getName latest)))
337
+ (println)
338
+ (with-open [r (io/reader latest)]
339
+ (let [entries (mapv #(json/parse-string % true) (line-seq r))
340
+ by-status (group-by :status entries)]
341
+ (doseq [[status tasks] (sort-by first by-status)]
342
+ (println (format "%s: %d" (name status) (count tasks))))
343
+ (println)
344
+ (println (format "Total: %d tasks" (count entries))))))
345
+ (println "No runs found."))))))
287
346
 
288
347
  (defn cmd-worktrees
289
348
  "List worktree status"
@@ -323,7 +382,7 @@
323
382
  "Check if agent backends are available"
324
383
  [opts args]
325
384
  (println "Checking agent backends...")
326
- (doseq [agent-type [:codex :claude]]
385
+ (doseq [agent-type [:codex :claude :opencode]]
327
386
  (let [available? (agent/check-available agent-type)]
328
387
  (println (format " %s: %s"
329
388
  (name agent-type)
@@ -354,14 +413,48 @@
354
413
  (println "{")
355
414
  (println " \"workers\": [")
356
415
  (println " {\"model\": \"codex:gpt-5.3-codex:medium\", \"prompt\": \"prompts/executor.md\", \"iterations\": 10, \"count\": 3, \"can_plan\": false},")
357
- (println " {\"model\": \"claude:opus\", \"prompt\": [\"prompts/base.md\", \"prompts/planner.md\"], \"count\": 1}")
416
+ (println " {\"model\": \"claude:opus\", \"prompt\": [\"prompts/base.md\", \"prompts/planner.md\"], \"count\": 1},")
417
+ (println " {\"model\": \"opencode:openai/gpt-5\", \"prompt\": [\"prompts/executor.md\"], \"count\": 1}")
358
418
  (println " ]")
359
419
  (println "}")
360
420
  (println)
361
421
  (println "prompt: string or array of paths — concatenated into one prompt.")
362
422
  (System/exit 1))
423
+ ;; Preflight: abort if git is dirty to prevent merge conflicts
424
+ (check-git-clean!)
425
+
363
426
  (let [config (json/parse-string (slurp f) true)
364
- review-model (some-> (:review_model config) parse-model-string)
427
+ ;; Parse reviewer config — supports both formats:
428
+ ;; Legacy: {"review_model": "harness:model:reasoning"}
429
+ ;; New: {"reviewer": {"model": "harness:model:reasoning", "prompt": ["path.md"]}}
430
+ reviewer-config (:reviewer config)
431
+ review-parsed (cond
432
+ reviewer-config
433
+ (let [parsed (parse-model-string (:model reviewer-config))
434
+ prompts (let [p (:prompt reviewer-config)]
435
+ (cond (vector? p) p
436
+ (string? p) [p]
437
+ :else []))]
438
+ (assoc parsed :prompts prompts))
439
+
440
+ (:review_model config)
441
+ (parse-model-string (:review_model config))
442
+
443
+ :else nil)
444
+
445
+ ;; Parse planner config — optional dedicated planner
446
+ ;; Runs in project root, no worktree/review/merge, respects max_pending backpressure
447
+ planner-config (:planner config)
448
+ planner-parsed (when planner-config
449
+ (let [parsed (parse-model-string (:model planner-config))
450
+ prompts (let [p (:prompt planner-config)]
451
+ (cond (vector? p) p
452
+ (string? p) [p]
453
+ :else []))]
454
+ (assoc parsed
455
+ :prompts prompts
456
+ :max-pending (or (:max_pending planner-config) 10))))
457
+
365
458
  worker-configs (:workers config)
366
459
 
367
460
  ;; Expand worker configs by count
@@ -383,14 +476,28 @@
383
476
  :iterations (or (:iterations wc) 10)
384
477
  :prompts (:prompt wc)
385
478
  :can-plan (:can_plan wc)
386
- :review-harness (:harness review-model)
387
- :review-model (:model review-model)})))
479
+ :review-harness (:harness review-parsed)
480
+ :review-model (:model review-parsed)
481
+ :review-prompts (:prompts review-parsed)})))
388
482
  expanded-workers)]
389
483
 
390
484
  (println (format "Swarm config from %s:" config-file))
391
485
  (println (format " Swarm ID: %s" swarm-id))
392
- (when review-model
393
- (println (format " Review: %s:%s" (name (:harness review-model)) (:model review-model))))
486
+ (when planner-parsed
487
+ (println (format " Planner: %s:%s (max_pending: %d%s)"
488
+ (name (:harness planner-parsed))
489
+ (:model planner-parsed)
490
+ (:max-pending planner-parsed)
491
+ (if (seq (:prompts planner-parsed))
492
+ (str ", prompts: " (str/join ", " (:prompts planner-parsed)))
493
+ ""))))
494
+ (when review-parsed
495
+ (println (format " Reviewer: %s:%s%s"
496
+ (name (:harness review-parsed))
497
+ (:model review-parsed)
498
+ (if (seq (:prompts review-parsed))
499
+ (str " (prompts: " (str/join ", " (:prompts review-parsed)) ")")
500
+ ""))))
394
501
  (println (format " Workers: %d total" (count workers)))
395
502
  (doseq [[idx wc] (map-indexed vector worker-configs)]
396
503
  (let [{:keys [harness model reasoning]} (parse-model-string (:model wc))]
@@ -404,7 +511,27 @@
404
511
  (println)
405
512
 
406
513
  ;; Preflight: probe each unique model before launching workers
407
- (validate-models! worker-configs review-model)
514
+ ;; Include planner model in validation if configured
515
+ (validate-models! (cond-> worker-configs
516
+ planner-config (conj planner-config))
517
+ review-parsed)
518
+
519
+ ;; Write run log to runs/{swarm-id}/run.edn
520
+ (runs/write-run-log! swarm-id
521
+ {:workers workers
522
+ :planner-config planner-parsed
523
+ :reviewer-config review-parsed
524
+ :config-file config-file})
525
+ (println (format "\nRun log written to runs/%s/run.edn" swarm-id))
526
+
527
+ ;; Run planner if configured — synchronously before workers
528
+ (when planner-parsed
529
+ (println)
530
+ (println (format " Planner: %s:%s (max_pending: %d)"
531
+ (name (:harness planner-parsed))
532
+ (:model planner-parsed)
533
+ (:max-pending planner-parsed)))
534
+ (worker/run-planner! (assoc planner-parsed :swarm-id swarm-id)))
408
535
 
409
536
  ;; Run workers using new worker module
410
537
  (worker/run-workers! workers))))
@@ -450,16 +577,16 @@
450
577
  (println)
451
578
  (println "Options:")
452
579
  (println " --workers N Number of parallel workers (default: 2)")
453
- (println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 codex:4)")
580
+ (println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 opencode:2)")
454
581
  (println " --iterations N Number of iterations per worker (default: 1)")
455
- (println " --harness {codex,claude} Agent harness to use (default: codex)")
456
- (println " --model MODEL Model to use (e.g., codex-5.2, opus-4.5)")
582
+ (println " --harness {codex,claude,opencode} Agent harness to use (default: codex)")
583
+ (println " --model MODEL Model to use (e.g., codex-5.2, opus-4.5, openai/gpt-5)")
457
584
  (println " --dry-run Skip actual merges")
458
585
  (println " --keep-worktrees Don't cleanup worktrees after run")
459
586
  (println)
460
587
  (println "Examples:")
461
588
  (println " ./swarm.bb loop 10 --harness codex --model gpt-5.3-codex --workers 3")
462
- (println " ./swarm.bb loop --workers claude:5 codex:4 --iterations 20")
589
+ (println " ./swarm.bb loop --workers claude:5 opencode:2 --iterations 20")
463
590
  (println " ./swarm.bb swarm oompa.json # Run multi-model config"))
464
591
 
465
592
  ;; =============================================================================
@@ -325,7 +325,7 @@
325
325
  "Convenience: create orchestrator, run, save log, shutdown.
326
326
 
327
327
  Arguments:
328
- opts - {:workers N, :harness :codex|:claude, :model string,
328
+ opts - {:workers N, :harness :codex|:claude|:opencode, :model string,
329
329
  :review-harness keyword, :review-model string,
330
330
  :custom-prompt string, :dry-run bool}
331
331
 
@@ -0,0 +1,165 @@
1
+ (ns agentnet.runs
2
+ "Structured persistence for swarm runs.
3
+
4
+ Layout:
5
+ runs/{swarm-id}/
6
+ run.json — start time, worker configs, planner output
7
+ summary.json — final metrics per worker, aggregate stats
8
+ reviews/
9
+ {worker-id}-i{N}-r{round}.json — per-iteration review log
10
+
11
+ Written as JSON for easy consumption by claude-web-view and CLI.
12
+ All writes are atomic (write to .tmp, rename) to avoid partial reads."
13
+ (:require [clojure.java.io :as io]
14
+ [clojure.string :as str]
15
+ [cheshire.core :as json]))
16
+
17
+ ;; =============================================================================
18
+ ;; Paths
19
+ ;; =============================================================================
20
+
21
+ (def ^:const RUNS_ROOT "runs")
22
+
23
+ (defn- run-dir [swarm-id]
24
+ (str RUNS_ROOT "/" swarm-id))
25
+
26
+ (defn- reviews-dir [swarm-id]
27
+ (str (run-dir swarm-id) "/reviews"))
28
+
29
+ (defn- ensure-run-dirs! [swarm-id]
30
+ (.mkdirs (io/file (reviews-dir swarm-id))))
31
+
32
+ ;; =============================================================================
33
+ ;; Atomic Write
34
+ ;; =============================================================================
35
+
36
+ (defn- write-json!
37
+ "Write JSON data atomically: write to .tmp, rename to final path."
38
+ [path data]
39
+ (let [f (io/file path)
40
+ tmp (io/file (str path ".tmp"))]
41
+ (.mkdirs (.getParentFile f))
42
+ (spit tmp (json/generate-string data {:pretty true}))
43
+ (.renameTo tmp f)))
44
+
45
+ ;; =============================================================================
46
+ ;; Run Log — written at swarm start
47
+ ;; =============================================================================
48
+
49
+ (defn write-run-log!
50
+ "Write the initial run log when a swarm starts.
51
+ Contains: start time, worker configs, planner output, swarm metadata."
52
+ [swarm-id {:keys [workers planner-config reviewer-config config-file]}]
53
+ (ensure-run-dirs! swarm-id)
54
+ (write-json! (str (run-dir swarm-id) "/run.json")
55
+ {:swarm-id swarm-id
56
+ :started-at (str (java.time.Instant/now))
57
+ :config-file config-file
58
+ :workers (mapv (fn [w]
59
+ {:id (:id w)
60
+ :harness (name (:harness w))
61
+ :model (:model w)
62
+ :reasoning (:reasoning w)
63
+ :iterations (:iterations w)
64
+ :can-plan (:can-plan w)
65
+ :prompts (:prompts w)})
66
+ workers)
67
+ :planner (when planner-config
68
+ {:harness (name (:harness planner-config))
69
+ :model (:model planner-config)
70
+ :prompts (:prompts planner-config)
71
+ :max-pending (:max-pending planner-config)})
72
+ :reviewer (when reviewer-config
73
+ {:harness (name (:harness reviewer-config))
74
+ :model (:model reviewer-config)
75
+ :prompts (:prompts reviewer-config)})}))
76
+
77
+ ;; =============================================================================
78
+ ;; Review Log — written after each review round
79
+ ;; =============================================================================
80
+
81
+ (defn write-review-log!
82
+ "Write a review log for one iteration of a worker.
83
+ Contains: verdict, round number, full reviewer output, diff file list."
84
+ [swarm-id worker-id iteration round
85
+ {:keys [verdict output diff-files]}]
86
+ (ensure-run-dirs! swarm-id)
87
+ (let [filename (format "%s-i%d-r%d.json" worker-id iteration round)]
88
+ (write-json! (str (reviews-dir swarm-id) "/" filename)
89
+ {:worker-id worker-id
90
+ :iteration iteration
91
+ :round round
92
+ :verdict (name verdict)
93
+ :timestamp (str (java.time.Instant/now))
94
+ :output output
95
+ :diff-files (vec diff-files)})))
96
+
97
+ ;; =============================================================================
98
+ ;; Swarm Summary — written at swarm end
99
+ ;; =============================================================================
100
+
101
+ (defn write-summary!
102
+ "Write the final swarm summary after all workers complete.
103
+ Contains: per-worker stats and aggregate metrics."
104
+ [swarm-id worker-results]
105
+ (let [total-completed (reduce + 0 (map :completed worker-results))
106
+ total-iterations (reduce + 0 (map :iterations worker-results))
107
+ statuses (frequencies (map #(name (:status %)) worker-results))
108
+ per-worker (mapv (fn [w]
109
+ {:id (:id w)
110
+ :harness (name (:harness w))
111
+ :model (:model w)
112
+ :status (name (:status w))
113
+ :completed (:completed w)
114
+ :iterations (:iterations w)
115
+ :merges (or (:merges w) 0)
116
+ :rejections (or (:rejections w) 0)
117
+ :errors (or (:errors w) 0)
118
+ :review-rounds-total (or (:review-rounds-total w) 0)})
119
+ worker-results)]
120
+ (write-json! (str (run-dir swarm-id) "/summary.json")
121
+ {:swarm-id swarm-id
122
+ :finished-at (str (java.time.Instant/now))
123
+ :total-workers (count worker-results)
124
+ :total-completed total-completed
125
+ :total-iterations total-iterations
126
+ :status-counts statuses
127
+ :workers per-worker})))
128
+
129
+ ;; =============================================================================
130
+ ;; Read helpers (for cmd-status, dashboards)
131
+ ;; =============================================================================
132
+
133
+ (defn list-runs
134
+ "List all swarm run directories, newest first."
135
+ []
136
+ (let [d (io/file RUNS_ROOT)]
137
+ (when (.exists d)
138
+ (->> (.listFiles d)
139
+ (filter #(.isDirectory %))
140
+ (sort-by #(.lastModified %) >)
141
+ (mapv #(.getName %))))))
142
+
143
+ (defn read-run-log
144
+ "Read run.json for a swarm."
145
+ [swarm-id]
146
+ (let [f (io/file (str (run-dir swarm-id) "/run.json"))]
147
+ (when (.exists f)
148
+ (json/parse-string (slurp f) true))))
149
+
150
+ (defn read-summary
151
+ "Read summary.json for a swarm."
152
+ [swarm-id]
153
+ (let [f (io/file (str (run-dir swarm-id) "/summary.json"))]
154
+ (when (.exists f)
155
+ (json/parse-string (slurp f) true))))
156
+
157
+ (defn list-reviews
158
+ "List all review logs for a swarm, sorted by filename."
159
+ [swarm-id]
160
+ (let [d (io/file (reviews-dir swarm-id))]
161
+ (when (.exists d)
162
+ (->> (.listFiles d)
163
+ (filter #(str/ends-with? (.getName %) ".json"))
164
+ (sort-by #(.getName %))
165
+ (mapv (fn [f] (json/parse-string (slurp f) true)))))))