@nbardy/oompa 0.7.0 → 0.7.1

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
@@ -85,7 +85,7 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
85
85
  "workers": [
86
86
  {"model": "claude:opus", "prompt": ["config/prompts/planner.md"], "iterations": 5, "count": 1},
87
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
+ {"model": "opencode:opencode/kimi-k2.5-free", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 1, "can_plan": false}
89
89
  ]
90
90
  }
91
91
  ```
@@ -93,13 +93,13 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
93
93
  This spawns:
94
94
  - **1 planner** (opus) — reads spec, explores codebase, creates/refines tasks
95
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`
96
+ - **1 opencode executor** (opencode/kimi-k2.5-free) — same task loop via `opencode run`
97
97
 
98
98
  #### Worker fields
99
99
 
100
100
  | Field | Required | Description |
101
101
  |-------|----------|-------------|
102
- | `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`, `opencode:openai/gpt-5`) |
102
+ | `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`, `opencode:opencode/kimi-k2.5-free`) |
103
103
  | `prompt` | no | String or array of paths — concatenated into one prompt |
104
104
  | `iterations` | no | Max iterations per worker (default: 10) |
105
105
  | `count` | no | Number of workers with this config (default: 1) |
@@ -113,7 +113,7 @@ This spawns:
113
113
  {
114
114
  "workers": [
115
115
  {"model": "claude:opus-4.5", "prompt": ["prompts/base.md", "prompts/architect.md"], "count": 1},
116
- {"model": "opencode:openai/gpt-5-mini", "prompt": ["prompts/base.md", "prompts/frontend.md"], "count": 2},
116
+ {"model": "opencode:opencode/kimi-k2.5-free", "prompt": ["prompts/base.md", "prompts/frontend.md"], "count": 2},
117
117
  {"model": "codex:codex-5.2-mini", "prompt": ["prompts/base.md", "prompts/backend.md"], "count": 2}
118
118
  ]
119
119
  }
@@ -121,6 +121,23 @@ This spawns:
121
121
 
122
122
  Every worker automatically gets task management instructions injected above your prompts. Your prompts just say *what* the worker should do — the framework handles *how* tasks work.
123
123
 
124
+ #### Prompt includes
125
+
126
+ Prompts support `#oompa_directive:include_file "path/to/file.md"` lines.
127
+
128
+ Use it to share common instructions across roles without copying content.
129
+ Paths are resolved relative to the prompt file containing the directive.
130
+
131
+ Example:
132
+
133
+ ```md
134
+ #oompa_directive:include_file "config/prompts/_agent_scope_rules.md"
135
+
136
+ You are an executor. Focus on minimal changes and complete tasks.
137
+ ```
138
+
139
+ The included file is inlined during prompt load, with a short header noting the injected source.
140
+
124
141
  ### Task System
125
142
 
126
143
  Workers self-organize via the filesystem. Tasks live at the project root and are shared across all worktrees:
@@ -88,6 +88,12 @@
88
88
  attach (into ["--attach" attach])
89
89
  true (conj prompt))))
90
90
 
91
+ (defmethod build-command :gemini
92
+ [_ {:keys [model]} prompt cwd]
93
+ (cond-> ["gemini" "--yolo"]
94
+ model (into ["-m" model])
95
+ true (into ["-p" prompt])))
96
+
91
97
  (defmethod build-command :default
92
98
  [agent-type _ _ _]
93
99
  (throw (ex-info (str "Unknown agent type: " agent-type)
@@ -125,7 +131,102 @@
125
131
  :stdout (truncate (:out result) 10000)
126
132
  :stderr (truncate (:err result) 5000)
127
133
  :duration-ms (- (now-ms) start)
128
- :timed-out? (boolean (:timed-out result))}))
134
+ :timed-out? (boolean (:timed-out result))}))
135
+
136
+ ;; =============================================================================
137
+ ;; Prompt Loading Helpers
138
+ ;; =============================================================================
139
+
140
+ (defn- file-canonical-path
141
+ "Resolve a path for cache keys and cycle detection."
142
+ [path]
143
+ (try
144
+ (.getCanonicalPath (io/file path))
145
+ (catch Exception _
146
+ path)))
147
+
148
+ (def ^:private prompt-file-cache
149
+ "Cache for prompt include expansion."
150
+ (atom {}))
151
+
152
+ (def ^:private include-directive-pattern
153
+ #"(?m)^\s*#oompa_directive:include_file\s+\"([^\"]+)\"\s*$")
154
+
155
+ (defn- read-file-cached
156
+ "Read a prompt file once and cache by canonical path."
157
+ [path]
158
+ (when path
159
+ (if-let [cached (get @prompt-file-cache path)]
160
+ cached
161
+ (let [f (io/file path)]
162
+ (when (.exists f)
163
+ (let [content (slurp f)]
164
+ (swap! prompt-file-cache assoc path content)
165
+ content))))))
166
+
167
+ (defn- resolve-include-path
168
+ "Resolve an include path relative to the file that declares it."
169
+ [source-path include-path]
170
+ (let [source-file (io/file source-path)
171
+ base-dir (.getParentFile source-file)]
172
+ (if (or (str/starts-with? include-path "/")
173
+ (and (> (count include-path) 1)
174
+ (= (nth include-path 1) \:)) ; Windows drive letter
175
+ (str/starts-with? include-path "~"))
176
+ include-path
177
+ (if base-dir
178
+ (str (io/file base-dir include-path))
179
+ include-path))))
180
+
181
+ (defn- expand-includes
182
+ "Expand #oompa_directive:include_file directives recursively.
183
+
184
+ Directive syntax:
185
+ #oompa_directive:include_file \"relative/or/absolute/path.md\"
186
+
187
+ Includes are resolved relative to the prompt file containing the directive.
188
+ Cycles are guarded by a simple visited-set."
189
+ ([raw source-path]
190
+ (expand-includes raw source-path #{}))
191
+ ([raw source-path visited]
192
+ (let [source-canonical (file-canonical-path source-path)
193
+ lines (str/split-lines (or raw ""))
194
+ visited' (conj visited source-canonical)]
195
+ (str/join
196
+ "\n"
197
+ (mapcat
198
+ (fn [line]
199
+ (if-let [match (re-matches include-directive-pattern line)]
200
+ (let [include-target (second match)
201
+ include-path (resolve-include-path source-canonical include-target)
202
+ include-canonical (file-canonical-path include-path)
203
+ included (and (not (str/blank? include-path))
204
+ (read-file-cached include-canonical))]
205
+ (cond
206
+ (str/blank? include-target)
207
+ ["[oompa] Empty include target in prompt directive"]
208
+
209
+ (contains? visited' include-canonical)
210
+ [(format "[oompa] Skipping already-included file: \"%s\"" include-target)]
211
+
212
+ (not included)
213
+ [(format "[oompa] Could not include \"%s\"" include-target)]
214
+
215
+ :else
216
+ (cons (format "We have included the content of file: \"%s\" below"
217
+ include-target)
218
+ (str/split-lines
219
+ (expand-includes included include-canonical visited')))))
220
+ [line]))
221
+ lines)))))
222
+
223
+ (defn- load-prompt-file
224
+ "Load a prompt file and expand include directives."
225
+ [path]
226
+ (when path
227
+ (when-let [f (io/file path)]
228
+ (when (.exists f)
229
+ (expand-includes (slurp f) (file-canonical-path path))))))
129
230
 
130
231
  ;; =============================================================================
131
232
  ;; Output Parsing
@@ -152,6 +253,17 @@
152
253
  [output]
153
254
  (boolean (re-find #"COMPLETE_AND_READY_FOR_MERGE" (or output ""))))
154
255
 
256
+ (defn parse-claim-signal
257
+ "Extract task IDs from CLAIM(...) signal in output.
258
+ Returns vector of task ID strings, or nil if no CLAIM signal found.
259
+ Format: CLAIM(task-001, task-003, task-005)"
260
+ [output]
261
+ (when-let [match (re-find #"CLAIM\(([^)]+)\)" (or output ""))]
262
+ (->> (str/split (second match) #",")
263
+ (map str/trim)
264
+ (remove str/blank?)
265
+ vec)))
266
+
155
267
  (defn- extract-comments
156
268
  "Extract bullet-point comments from output"
157
269
  [output]
@@ -228,7 +340,7 @@
228
340
  (let [filename (str "config/prompts/" (name role) ".md")
229
341
  f (io/file filename)]
230
342
  (when (.exists f)
231
- (slurp f))))
343
+ (load-prompt-file filename))))
232
344
 
233
345
  (defn load-custom-prompt
234
346
  "Load a custom prompt file. Returns content or nil."
@@ -236,10 +348,11 @@
236
348
  (when path
237
349
  (let [f (io/file path)]
238
350
  (when (.exists f)
239
- (slurp f)))))
351
+ (load-prompt-file path)))))
240
352
 
241
- (defn- tokenize
242
- "Replace {tokens} in template with values from context map"
353
+ (defn tokenize
354
+ "Replace {tokens} in template with values from context map.
355
+ Keys can be keywords or strings; values are stringified."
243
356
  [template tokens]
244
357
  (reduce (fn [acc [k v]]
245
358
  (str/replace acc
@@ -287,12 +400,18 @@
287
400
  :codex ["codex" "--version"]
288
401
  :claude ["claude" "--version"]
289
402
  :opencode ["opencode" "--version"]
403
+ :gemini ["gemini" "--version"]
290
404
  ["echo" "unknown"])]
291
405
  (try
292
406
  (let [{:keys [exit]} (process/sh cmd {:out :string :err :string})]
293
407
  (zero? exit))
294
408
  (catch Exception _
295
- false))))
409
+ ;; Some CLIs (like gemini) may error on --version due to config issues
410
+ ;; but still exist on PATH. Fall back to `which`.
411
+ (try
412
+ (let [{:keys [exit]} (process/sh ["which" (first cmd)] {:out :string :err :string})]
413
+ (zero? exit))
414
+ (catch Exception _ false))))))
296
415
 
297
416
  (defn select-backend
298
417
  "Select first available backend from preference list"
@@ -16,6 +16,7 @@
16
16
  [agentnet.worker :as worker]
17
17
  [agentnet.tasks :as tasks]
18
18
  [agentnet.agent :as agent]
19
+ [agentnet.harness :as harness]
19
20
  [agentnet.runs :as runs]
20
21
  [babashka.process :as process]
21
22
  [clojure.string :as str]
@@ -31,7 +32,7 @@
31
32
  (Integer/parseInt s)
32
33
  (catch Exception _ default)))
33
34
 
34
- (def ^:private harnesses #{:codex :claude :opencode})
35
+ (def ^:private harnesses (harness/known-harnesses))
35
36
 
36
37
  (defn- make-swarm-id
37
38
  "Generate a short run-level swarm ID."
@@ -46,7 +47,7 @@
46
47
  h (keyword harness)
47
48
  cnt (parse-int count-str 0)]
48
49
  (when-not (harnesses h)
49
- (throw (ex-info (str "Unknown harness in worker spec: " s ". Use 'codex:N', 'claude:N', or 'opencode:N'") {})))
50
+ (throw (ex-info (str "Unknown harness in worker spec: " s ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
50
51
  (when (zero? cnt)
51
52
  (throw (ex-info (str "Invalid count in worker spec: " s ". Use format 'harness:count'") {})))
52
53
  {:harness h :count cnt}))
@@ -97,7 +98,7 @@
97
98
  (= arg "--harness")
98
99
  (let [h (keyword (second remaining))]
99
100
  (when-not (harnesses h)
100
- (throw (ex-info (str "Unknown harness: " (second remaining) ". Use 'codex', 'claude', or 'opencode'") {})))
101
+ (throw (ex-info (str "Unknown harness: " (second remaining) ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
101
102
  (recur (assoc opts :harness h)
102
103
  (nnext remaining)))
103
104
 
@@ -153,15 +154,52 @@
153
154
  (println "Run 'git stash' or 'git commit' first.")
154
155
  (System/exit 1))))
155
156
 
157
+ (defn- check-stale-worktrees!
158
+ "Abort if stale oompa worktrees or branches exist from a prior run.
159
+ Corrupted .git/worktrees/ entries poison git worktree add for ALL workers,
160
+ not just the worker whose entry is stale. (See swarm af32b180 — kimi-k2.5
161
+ w9 went 20/20 doing nothing because w10's corrupt commondir blocked it.)"
162
+ []
163
+ ;; Prune orphaned metadata first — cleans entries whose directories are gone
164
+ (let [prune-result (process/sh ["git" "worktree" "prune"] {:out :string :err :string})]
165
+ (when-not (zero? (:exit prune-result))
166
+ (println "WARNING: git worktree prune failed:")
167
+ (println (:err prune-result))))
168
+ (let [;; Find .ww* directories (oompa per-iteration worktree naming convention)
169
+ ls-result (process/sh ["find" "." "-maxdepth" "1" "-type" "d" "-name" ".ww*"]
170
+ {:out :string})
171
+ stale-dirs (when (zero? (:exit ls-result))
172
+ (->> (str/split-lines (:out ls-result))
173
+ (remove str/blank?)))
174
+ ;; Find oompa/* branches
175
+ br-result (process/sh ["git" "branch" "--list" "oompa/*"]
176
+ {:out :string})
177
+ stale-branches (when (zero? (:exit br-result))
178
+ (->> (str/split-lines (:out br-result))
179
+ (map str/trim)
180
+ (remove str/blank?)))]
181
+ (when (or (seq stale-dirs) (seq stale-branches))
182
+ (println "ERROR: Stale oompa worktrees detected from a prior run.")
183
+ (println " Corrupt worktree metadata will cause worker failures.")
184
+ (println)
185
+ (when (seq stale-dirs)
186
+ (println (format " Stale directories (%d):" (count stale-dirs)))
187
+ (doseq [d stale-dirs] (println (str " " d))))
188
+ (when (seq stale-branches)
189
+ (println (format " Stale branches (%d):" (count stale-branches)))
190
+ (doseq [b stale-branches] (println (str " " b))))
191
+ (println)
192
+ (println "Clean up with:")
193
+ (println " git worktree prune; for d in .ww*/; do git worktree remove --force \"$d\" 2>/dev/null; done; git branch --list 'oompa/*' | xargs git branch -D 2>/dev/null; rm -rf .ww*")
194
+ (println)
195
+ (System/exit 1))))
196
+
156
197
  (defn- probe-model
157
198
  "Send 'say ok' to a model via its harness CLI. Returns true if model responds.
158
- Claude hangs without /dev/null stdin when spawned from bb."
159
- [harness model]
199
+ Uses harness/build-probe-cmd for the command, /dev/null stdin to prevent hang."
200
+ [harness-kw model]
160
201
  (try
161
- (let [cmd (case harness
162
- :claude ["claude" "--model" model "-p" "[oompa:probe] say ok" "--max-turns" "1"]
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"])
202
+ (let [cmd (harness/build-probe-cmd harness-kw model)
165
203
  null-in (io/input-stream (io/file "/dev/null"))
166
204
  proc (process/process cmd {:out :string :err :string :in null-in})
167
205
  result (deref proc 30000 :timeout)]
@@ -225,10 +263,10 @@
225
263
  (println (format " %dx %s" (:count spec) (name (:harness spec)))))
226
264
  (println)
227
265
  (worker/run-workers! workers))
228
- ;; Simple mode
266
+ ;; Simple mode retired — use oompa.json or --workers harness:count
229
267
  (do
230
- (println (format "Swarm ID: %s" swarm-id))
231
- (orchestrator/run-once! (assoc opts :swarm-id swarm-id)))))))
268
+ (println "Simple mode is no longer supported. Use oompa.json or --workers harness:count.")
269
+ (System/exit 1))))))
232
270
 
233
271
  (defn cmd-loop
234
272
  "Run orchestrator N times"
@@ -257,14 +295,10 @@
257
295
  (println (format " %dx %s" (:count spec) (name (:harness spec)))))
258
296
  (println)
259
297
  (worker/run-workers! workers))
260
- ;; Simple mode: --workers N --harness X
261
- (let [model-str (if (:model opts)
262
- (format " (model: %s)" (:model opts))
263
- "")]
264
- (println (format "Starting %d iterations with %s harness%s..."
265
- iterations (name (:harness opts)) model-str))
266
- (println (format "Swarm ID: %s" swarm-id))
267
- (orchestrator/run-loop! iterations (assoc opts :swarm-id swarm-id))))))
298
+ ;; Simple mode retired — use oompa.json or --workers harness:count
299
+ (do
300
+ (println "Simple mode is no longer supported. Use oompa.json or --workers harness:count.")
301
+ (System/exit 1)))))
268
302
 
269
303
  (defn cmd-prompt
270
304
  "Run ad-hoc prompt as single task"
@@ -284,46 +318,45 @@
284
318
  (orchestrator/run-once! opts))))
285
319
 
286
320
  (defn cmd-status
287
- "Show status of last run — reads structured runs/{swarm-id}/ data."
321
+ "Show status of last run — reads event-sourced runs/{swarm-id}/ data."
288
322
  [opts args]
289
323
  (let [run-ids (runs/list-runs)]
290
324
  (if (seq run-ids)
291
325
  (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)
326
+ started (runs/read-started swarm-id)
327
+ stopped (runs/read-stopped swarm-id)
328
+ cycles (runs/list-cycles swarm-id)
294
329
  reviews (runs/list-reviews swarm-id)]
295
330
  (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)))))
331
+ (when started
332
+ (println (format " Started: %s" (:started-at started)))
333
+ (println (format " PID: %s" (or (:pid started) "N/A")))
334
+ (println (format " Config: %s" (or (:config-file started) "N/A")))
335
+ (println (format " Workers: %d" (count (:workers started)))))
300
336
  (println)
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))))
307
- (println)
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)"))
337
+ (if stopped
338
+ (println (format "Stopped: %s (reason: %s%s)"
339
+ (:stopped-at stopped)
340
+ (:reason stopped)
341
+ (if (:error stopped)
342
+ (str ", error: " (:error stopped))
343
+ "")))
344
+ (println " (still running — no stopped event yet)"))
345
+ (when (seq cycles)
346
+ (println)
347
+ (println (format "Cycles: %d total" (count cycles)))
348
+ (doseq [c cycles]
349
+ (println (format " %s-c%d: %s (%dms, claimed: %s)"
350
+ (:worker-id c) (:cycle c)
351
+ (:outcome c)
352
+ (or (:duration-ms c) 0)
353
+ (str/join ", " (or (:claimed-task-ids c) []))))))
321
354
  (when (seq reviews)
322
355
  (println)
323
356
  (println (format "Reviews: %d total" (count reviews)))
324
357
  (doseq [r reviews]
325
- (println (format " %s-i%d-r%d: %s"
326
- (:worker-id r) (:iteration r) (:round r)
358
+ (println (format " %s-c%d-r%d: %s"
359
+ (:worker-id r) (:cycle r) (:round r)
327
360
  (:verdict r))))))
328
361
  ;; Fall back to legacy JSONL format
329
362
  (let [runs-dir (io/file "runs")
@@ -382,21 +415,43 @@
382
415
  "Check if agent backends are available"
383
416
  [opts args]
384
417
  (println "Checking agent backends...")
385
- (doseq [agent-type [:codex :claude :opencode]]
386
- (let [available? (agent/check-available agent-type)]
418
+ (doseq [harness-kw (sort (harness/known-harnesses))]
419
+ (let [available? (harness/check-available harness-kw)]
387
420
  (println (format " %s: %s"
388
- (name agent-type)
421
+ (name harness-kw)
389
422
  (if available? "✓ available" "✗ not found"))))))
390
423
 
424
+ (def ^:private reasoning-variants
425
+ #{"minimal" "low" "medium" "high" "max" "xhigh"})
426
+
391
427
  (defn- parse-model-string
392
428
  "Parse model string into {:harness :model :reasoning}.
393
- Formats: 'harness:model', 'harness:model:reasoning', or just 'model'."
429
+
430
+ Supported formats:
431
+ - harness:model
432
+ - harness:model:reasoning (codex only)
433
+ - model (defaults harness to :codex)
434
+
435
+ Note: non-codex model identifiers may contain ':' (for example
436
+ openrouter/...:free). Those suffixes are preserved in :model."
394
437
  [s]
395
438
  (if (and s (str/includes? s ":"))
396
- (let [parts (str/split s #":" 3)]
397
- (case (count parts)
398
- 2 {:harness (keyword (first parts)) :model (second parts)}
399
- 3 {:harness (keyword (first parts)) :model (second parts) :reasoning (nth parts 2)}
439
+ (let [[harness-str rest*] (str/split s #":" 2)
440
+ harness (keyword harness-str)]
441
+ (if (contains? harnesses harness)
442
+ (if (= harness :codex)
443
+ ;; Codex may include a reasoning suffix at the end. Only treat the
444
+ ;; last segment as reasoning if it matches a known variant.
445
+ (if-let [idx (str/last-index-of rest* ":")]
446
+ (let [model* (subs rest* 0 idx)
447
+ reasoning* (subs rest* (inc idx))]
448
+ (if (contains? reasoning-variants reasoning*)
449
+ {:harness harness :model model* :reasoning reasoning*}
450
+ {:harness harness :model rest*}))
451
+ {:harness harness :model rest*})
452
+ ;; Non-codex: preserve full model string (including any ':suffix').
453
+ {:harness harness :model rest*})
454
+ ;; Not a known harness prefix, treat as raw model on default harness.
400
455
  {:harness :codex :model s}))
401
456
  {:harness :codex :model s}))
402
457
 
@@ -414,7 +469,7 @@
414
469
  (println " \"workers\": [")
415
470
  (println " {\"model\": \"codex:gpt-5.3-codex:medium\", \"prompt\": \"prompts/executor.md\", \"iterations\": 10, \"count\": 3, \"can_plan\": false},")
416
471
  (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}")
472
+ (println " {\"model\": \"gemini:gemini-3-pro-preview\", \"prompt\": [\"prompts/executor.md\"], \"count\": 1}")
418
473
  (println " ]")
419
474
  (println "}")
420
475
  (println)
@@ -422,6 +477,8 @@
422
477
  (System/exit 1))
423
478
  ;; Preflight: abort if git is dirty to prevent merge conflicts
424
479
  (check-git-clean!)
480
+ ;; Preflight: abort if stale worktrees from prior runs would poison git
481
+ (check-stale-worktrees!)
425
482
 
426
483
  (let [config (json/parse-string (slurp f) true)
427
484
  ;; Parse reviewer config — supports both formats:
@@ -476,6 +533,8 @@
476
533
  :iterations (or (:iterations wc) 10)
477
534
  :prompts (:prompt wc)
478
535
  :can-plan (:can_plan wc)
536
+ :wait-between (:wait_between wc)
537
+ :max-working-resumes (:max_working_resumes wc)
479
538
  :review-harness (:harness review-parsed)
480
539
  :review-model (:model review-parsed)
481
540
  :review-prompts (:prompts review-parsed)})))
@@ -516,13 +575,13 @@
516
575
  planner-config (conj planner-config))
517
576
  review-parsed)
518
577
 
519
- ;; Write run log to runs/{swarm-id}/run.edn
520
- (runs/write-run-log! swarm-id
578
+ ;; Write started event to runs/{swarm-id}/started.json
579
+ (runs/write-started! swarm-id
521
580
  {:workers workers
522
581
  :planner-config planner-parsed
523
582
  :reviewer-config review-parsed
524
583
  :config-file config-file})
525
- (println (format "\nRun log written to runs/%s/run.edn" swarm-id))
584
+ (println (format "\nStarted event written to runs/%s/started.json" swarm-id))
526
585
 
527
586
  ;; Run planner if configured — synchronously before workers
528
587
  (when planner-parsed
@@ -555,6 +614,69 @@
555
614
  (doseq [t (tasks/list-current)]
556
615
  (println (format " - %s: %s" (:id t) (:summary t)))))))
557
616
 
617
+ (defn- find-latest-swarm-id
618
+ "Find the most recent swarm ID from runs/ directory."
619
+ []
620
+ (first (runs/list-runs)))
621
+
622
+ (defn- read-swarm-pid
623
+ "Read PID from started.json for a swarm. Returns nil if not found."
624
+ [swarm-id]
625
+ (when-let [started (runs/read-started swarm-id)]
626
+ (:pid started)))
627
+
628
+ (defn- pid-alive?
629
+ "Check if a process is alive via kill -0."
630
+ [pid]
631
+ (try
632
+ (zero? (:exit (process/sh ["kill" "-0" (str pid)]
633
+ {:out :string :err :string})))
634
+ (catch Exception _ false)))
635
+
636
+ (defn cmd-stop
637
+ "Send SIGTERM to running swarm — workers finish current cycle then exit"
638
+ [opts args]
639
+ (let [swarm-id (or (first args) (find-latest-swarm-id))]
640
+ (if-not swarm-id
641
+ (println "No swarm runs found.")
642
+ (let [stopped (runs/read-stopped swarm-id)]
643
+ (if stopped
644
+ (println (format "Swarm %s already stopped (reason: %s)" swarm-id (:reason stopped)))
645
+ (let [pid (read-swarm-pid swarm-id)]
646
+ (if-not pid
647
+ (println (format "No PID found for swarm %s" swarm-id))
648
+ (if-not (pid-alive? pid)
649
+ (do
650
+ (println (format "Swarm %s PID %s is not running (stale). Writing stopped event." swarm-id pid))
651
+ (runs/write-stopped! swarm-id :interrupted))
652
+ (do
653
+ (println (format "Sending SIGTERM to swarm %s (PID %s)..." swarm-id pid))
654
+ (println "Workers will finish their current cycle and exit.")
655
+ (process/sh ["kill" (str pid)]))))))))))
656
+
657
+ (defn cmd-kill
658
+ "Send SIGKILL to running swarm — immediate termination"
659
+ [opts args]
660
+ (let [swarm-id (or (first args) (find-latest-swarm-id))]
661
+ (if-not swarm-id
662
+ (println "No swarm runs found.")
663
+ (let [stopped (runs/read-stopped swarm-id)]
664
+ (if stopped
665
+ (println (format "Swarm %s already stopped (reason: %s)" swarm-id (:reason stopped)))
666
+ (let [pid (read-swarm-pid swarm-id)]
667
+ (if-not pid
668
+ (println (format "No PID found for swarm %s" swarm-id))
669
+ (if-not (pid-alive? pid)
670
+ (do
671
+ (println (format "Swarm %s PID %s is not running (stale). Writing stopped event." swarm-id pid))
672
+ (runs/write-stopped! swarm-id :interrupted))
673
+ (do
674
+ (println (format "Sending SIGKILL to swarm %s (PID %s)..." swarm-id pid))
675
+ ;; SIGKILL bypasses JVM shutdown hooks, so write stopped.json here
676
+ (process/sh ["kill" "-9" (str pid)])
677
+ (runs/write-stopped! swarm-id :interrupted)
678
+ (println "Swarm killed."))))))))))
679
+
558
680
  (defn cmd-help
559
681
  "Print usage information"
560
682
  [opts args]
@@ -570,6 +692,8 @@
570
692
  (println " prompt \"...\" Run ad-hoc prompt")
571
693
  (println " status Show last run summary")
572
694
  (println " worktrees List worktree status")
695
+ (println " stop [swarm-id] Stop swarm gracefully (finish current cycle)")
696
+ (println " kill [swarm-id] Kill swarm immediately (SIGKILL)")
573
697
  (println " cleanup Remove all worktrees")
574
698
  (println " context Print context block")
575
699
  (println " check Check agent backends")
@@ -579,8 +703,8 @@
579
703
  (println " --workers N Number of parallel workers (default: 2)")
580
704
  (println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 opencode:2)")
581
705
  (println " --iterations N Number of iterations per worker (default: 1)")
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)")
706
+ (println (str " --harness {" (str/join "," (map name (sort harnesses))) "} Agent harness to use (default: codex)"))
707
+ (println " --model MODEL Model to use (e.g., codex:gpt-5.3-codex:medium, claude:opus, gemini:gemini-3-pro-preview)")
584
708
  (println " --dry-run Skip actual merges")
585
709
  (println " --keep-worktrees Don't cleanup worktrees after run")
586
710
  (println)
@@ -600,6 +724,8 @@
600
724
  "tasks" cmd-tasks
601
725
  "prompt" cmd-prompt
602
726
  "status" cmd-status
727
+ "stop" cmd-stop
728
+ "kill" cmd-kill
603
729
  "worktrees" cmd-worktrees
604
730
  "cleanup" cmd-cleanup
605
731
  "context" cmd-context