@nbardy/oompa 0.5.1 → 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.
@@ -15,80 +15,12 @@
15
15
  (:require [agentnet.tasks :as tasks]
16
16
  [agentnet.agent :as agent]
17
17
  [agentnet.worktree :as worktree]
18
+ [agentnet.runs :as runs]
19
+ [cheshire.core :as json]
18
20
  [babashka.process :as process]
19
21
  [clojure.java.io :as io]
20
22
  [clojure.string :as str]))
21
23
 
22
- ;; =============================================================================
23
- ;; codex-persist integration
24
- ;; =============================================================================
25
-
26
- (def ^:private persist-cmd* (atom nil))
27
- (def ^:private persist-missing-warned?* (atom false))
28
-
29
- (defn- command-ok?
30
- "Return true if command vector is executable (exit code ignored)."
31
- [cmd]
32
- (try
33
- (do
34
- (process/sh (vec cmd) {:out :string :err :string :continue true})
35
- true)
36
- (catch Exception _
37
- false)))
38
-
39
- (defn- resolve-codex-persist-cmd
40
- "Resolve codex-persist command vector.
41
- Order:
42
- 1) CODEX_PERSIST_BIN env var
43
- 2) codex-persist on PATH
44
- 3) node ~/git/codex-persist/dist/cli.js"
45
- []
46
- (let [cached @persist-cmd*]
47
- (if (some? cached)
48
- cached
49
- (let [env-bin (System/getenv "CODEX_PERSIST_BIN")
50
- env-cmd (when (and env-bin (not (str/blank? env-bin)))
51
- [env-bin])
52
- path-cmd ["codex-persist"]
53
- local-cli (str (System/getProperty "user.home") "/git/codex-persist/dist/cli.js")
54
- local-cmd (when (.exists (io/file local-cli))
55
- ["node" local-cli])
56
- cmd (cond
57
- (and env-cmd (command-ok? env-cmd)) env-cmd
58
- (command-ok? path-cmd) path-cmd
59
- (and local-cmd (command-ok? local-cmd)) local-cmd
60
- :else false)]
61
- (reset! persist-cmd* cmd)
62
- cmd))))
63
-
64
- (defn- safe-assistant-content
65
- "Pick a non-empty assistant message payload for persistence."
66
- [result]
67
- (let [out (or (:out result) "")
68
- err (or (:err result) "")
69
- exit-code (or (:exit result) -1)]
70
- (cond
71
- (not (str/blank? out)) out
72
- (not (str/blank? err)) (str "[agent stderr] " err)
73
- :else (str "[agent exit " exit-code "]"))))
74
-
75
- (defn- persist-message!
76
- "Write a single message to codex-persist; no-op if unavailable."
77
- [worker-id session-id cwd role content]
78
- (let [resolved (resolve-codex-persist-cmd)]
79
- (if (and resolved (not= resolved false))
80
- (let [persist-cmd resolved
81
- payload (if (str/blank? content) "(empty)" content)
82
- result (try
83
- (process/sh (into persist-cmd ["write" session-id cwd role payload])
84
- {:out :string :err :string})
85
- (catch Exception e
86
- {:exit -1 :out "" :err (.getMessage e)}))]
87
- (when-not (zero? (:exit result))
88
- (println (format "[%s] codex-persist write failed (%s)" worker-id role))))
89
- (when (compare-and-set! persist-missing-warned?* false true)
90
- (println "[oompa] codex-persist not found; set CODEX_PERSIST_BIN or install/link codex-persist")))))
91
-
92
24
  ;; =============================================================================
93
25
  ;; Worker State
94
26
  ;; =============================================================================
@@ -97,6 +29,10 @@
97
29
  "Root of the oompa package — set by bin/oompa.js, falls back to cwd."
98
30
  (or (System/getenv "OOMPA_PACKAGE_ROOT") "."))
99
31
 
32
+ ;; Serializes merge-to-main! calls across concurrent workers to prevent
33
+ ;; git index corruption from parallel checkout+merge operations.
34
+ (def ^:private merge-lock (Object.))
35
+
100
36
  ;; Resolve absolute paths for CLI binaries at first use.
101
37
  ;; ProcessBuilder with :dir set can fail to find bare command names on some
102
38
  ;; platforms (macOS + babashka), so we resolve once via `which` and cache.
@@ -127,8 +63,10 @@
127
63
  "Create a worker config.
128
64
  :prompts is a string or vector of strings — paths to prompt files.
129
65
  :can-plan when false, worker waits for tasks before starting (backpressure).
130
- :reasoning reasoning effort level (e.g. \"low\", \"medium\", \"high\") — codex only."
131
- [{:keys [id swarm-id harness model iterations prompts can-plan reasoning review-harness review-model]}]
66
+ :reasoning reasoning effort level (e.g. \"low\", \"medium\", \"high\") — codex only.
67
+ :review-prompts paths to reviewer prompt files (loaded and concatenated for review)."
68
+ [{:keys [id swarm-id harness model iterations prompts can-plan reasoning
69
+ review-harness review-model review-prompts]}]
132
70
  {:id id
133
71
  :swarm-id swarm-id
134
72
  :harness (or harness :codex)
@@ -142,6 +80,10 @@
142
80
  :reasoning reasoning
143
81
  :review-harness review-harness
144
82
  :review-model review-model
83
+ :review-prompts (cond
84
+ (vector? review-prompts) review-prompts
85
+ (string? review-prompts) [review-prompts]
86
+ :else [])
145
87
  :completed 0
146
88
  :status :idle})
147
89
 
@@ -164,33 +106,83 @@
164
106
  :task_status (format "Pending: %d, In Progress: %d, Complete: %d"
165
107
  (count pending) (count current) (count complete))}))
166
108
 
109
+ (defn- opencode-attach-url
110
+ "Optional opencode server URL for run --attach mode."
111
+ []
112
+ (let [url (or (System/getenv "OOMPA_OPENCODE_ATTACH")
113
+ (System/getenv "OPENCODE_ATTACH"))]
114
+ (when (and url (not (str/blank? url)))
115
+ url)))
116
+
117
+ (defn- parse-opencode-run-output
118
+ "Parse `opencode run --format json` output.
119
+ Returns {:session-id string|nil, :text string|nil}."
120
+ [s]
121
+ (let [raw (or s "")
122
+ events (->> (str/split-lines raw)
123
+ (keep (fn [line]
124
+ (try
125
+ (json/parse-string line true)
126
+ (catch Exception _
127
+ nil))))
128
+ doall)
129
+ session-id (or (some #(or (:sessionID %)
130
+ (:sessionId %)
131
+ (get-in % [:part :sessionID])
132
+ (get-in % [:part :sessionId]))
133
+ events)
134
+ (some-> (re-find #"(ses_[A-Za-z0-9]+)" raw) second))
135
+ text (->> events
136
+ (keep (fn [event]
137
+ (let [event-type (or (:type event) (get-in event [:part :type]))
138
+ chunk (or (:text event) (get-in event [:part :text]))]
139
+ (when (and (= event-type "text")
140
+ (string? chunk)
141
+ (not (str/blank? chunk)))
142
+ chunk))))
143
+ (str/join ""))]
144
+ {:session-id session-id
145
+ :text (when-not (str/blank? text) text)}))
146
+
167
147
  (defn- run-agent!
168
- "Run agent with prompt, return {:output string, :done? bool, :exit int}"
169
- [{:keys [id swarm-id harness model prompts reasoning]} worktree-path context]
170
- (let [;; 1. Task header (always, from package)
171
- task-header (or (load-prompt "config/prompts/_task_header.md") "")
172
-
173
- ;; 2. Load and concatenate all user prompts (string or list)
174
- user-prompts (if (seq prompts)
175
- (->> prompts
176
- (map load-prompt)
177
- (remove nil?)
178
- (str/join "\n\n"))
179
- (or (load-prompt "config/prompts/worker.md")
180
- "You are a worker. Claim tasks, execute them, complete them."))
181
-
182
- ;; Assemble: task header + status + user prompts
183
- full-prompt (str task-header "\n"
184
- "Task Status: " (:task_status context) "\n"
185
- "Pending: " (:pending_tasks context) "\n\n"
186
- user-prompts)
187
- session-id (str/lower-case (str (java.util.UUID/randomUUID)))
148
+ "Run agent with prompt, return {:output string, :done? bool, :merge? bool, :exit int, :session-id string}.
149
+ When resume? is true and harness is :claude/:opencode, continues the existing session
150
+ with a lighter prompt (just task status + continue instruction)."
151
+ [{:keys [id swarm-id harness model prompts reasoning]} worktree-path context session-id resume?]
152
+ (let [;; Use provided session-id, otherwise generate one for harnesses that accept custom IDs.
153
+ session-id (or session-id
154
+ (when (#{:codex :claude} harness)
155
+ (str/lower-case (str (java.util.UUID/randomUUID)))))
156
+
157
+ ;; Build prompt — lighter for resume (agent already has full context)
158
+ prompt (if resume?
159
+ (str "Task Status: " (:task_status context) "\n"
160
+ "Pending: " (:pending_tasks context) "\n\n"
161
+ "Continue working. Signal COMPLETE_AND_READY_FOR_MERGE when your current task is done and ready for review.")
162
+ (let [task-header (or (load-prompt "config/prompts/_task_header.md") "")
163
+ user-prompts (if (seq prompts)
164
+ (->> prompts
165
+ (map load-prompt)
166
+ (remove nil?)
167
+ (str/join "\n\n"))
168
+ (or (load-prompt "config/prompts/worker.md")
169
+ "You are a worker. Claim tasks, execute them, complete them."))]
170
+ (str task-header "\n"
171
+ "Task Status: " (:task_status context) "\n"
172
+ "Pending: " (:pending_tasks context) "\n\n"
173
+ user-prompts)))
174
+
188
175
  swarm-id* (or swarm-id "unknown")
189
- tagged-prompt (str "[oompa:" swarm-id* ":" id "] " full-prompt)
176
+ tagged-prompt (str "[oompa:" swarm-id* ":" id "] " prompt)
190
177
  abs-worktree (.getAbsolutePath (io/file worktree-path))
178
+ opencode-attach (opencode-attach-url)
191
179
 
192
- ;; Build command — both harnesses run with cwd=worktree, no sandbox
180
+ ;; Build command — all harnesses run with cwd=worktree, no sandbox
193
181
  ;; so agents can `..` to reach project root for task management
182
+ ;; Claude: --resume flag continues existing session-id conversation
183
+ ;; Opencode: -s/--session + --continue continue existing session
184
+ ;; and --format json for deterministic per-run session capture.
185
+ ;; Codex: no native resume support, always fresh (but worktree state persists)
194
186
  cmd (case harness
195
187
  :codex (cond-> [(resolve-binary! "codex") "exec"
196
188
  "--dangerously-bypass-approvals-and-sandbox"
@@ -198,54 +190,86 @@
198
190
  "-C" abs-worktree]
199
191
  model (into ["--model" model])
200
192
  reasoning (into ["-c" (str "model_reasoning_effort=\"" reasoning "\"")])
201
- true (conj "--" full-prompt))
193
+ true (conj "--" tagged-prompt))
202
194
  :claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"
203
195
  "--session-id" session-id]
204
- model (into ["--model" model])))
205
-
206
- _ (when (= harness :codex)
207
- (persist-message! id session-id abs-worktree "user" tagged-prompt))
208
-
209
- ;; Run agent both run with cwd=worktree
196
+ resume? (conj "--resume")
197
+ model (into ["--model" model]))
198
+ :opencode (cond-> [(resolve-binary! "opencode") "run" "--format" "json"]
199
+ model (into ["-m" model])
200
+ opencode-attach (into ["--attach" opencode-attach])
201
+ (and resume? session-id) (into ["-s" session-id "--continue"])
202
+ true (conj tagged-prompt)))
203
+
204
+ ;; Run agent — all run with cwd=worktree
210
205
  result (try
211
206
  (if (= harness :claude)
212
207
  (process/sh cmd {:dir abs-worktree :in tagged-prompt :out :string :err :string})
213
208
  (process/sh cmd {:dir abs-worktree :out :string :err :string}))
214
209
  (catch Exception e
215
210
  (println (format "[%s] Agent exception: %s" id (.getMessage e)))
216
- {:exit -1 :out "" :err (.getMessage e)}))]
217
-
218
- (when (= harness :codex)
219
- (persist-message! id session-id abs-worktree "assistant" (safe-assistant-content result)))
220
-
221
- {:output (:out result)
211
+ {:exit -1 :out "" :err (.getMessage e)}))
212
+ parsed-opencode (when (= harness :opencode)
213
+ (parse-opencode-run-output (:out result)))
214
+ output (if (= harness :opencode)
215
+ (or (:text parsed-opencode) (:out result))
216
+ (:out result))
217
+ session-id' (if (= harness :opencode)
218
+ (or (:session-id parsed-opencode) session-id)
219
+ session-id)]
220
+
221
+ {:output output
222
222
  :exit (:exit result)
223
- :done? (agent/done-signal? (:out result))}))
223
+ :done? (agent/done-signal? output)
224
+ :merge? (agent/merge-signal? output)
225
+ :session-id session-id'}))
224
226
 
225
227
  (defn- run-reviewer!
226
228
  "Run reviewer on worktree changes.
227
- Returns {:verdict :approved|:needs-changes|:rejected, :comments [...]}"
228
- [{:keys [id swarm-id review-harness review-model]} worktree-path]
229
- (let [;; Get diff for context
230
- diff-result (process/sh ["git" "diff" "main" "--stat"]
229
+ Uses custom review-prompts when configured, otherwise falls back to default.
230
+ prev-feedback: vector of previous review outputs (for multi-round context).
231
+ Returns {:verdict :approved|:needs-changes|:rejected, :comments [...], :output string}"
232
+ [{:keys [id swarm-id review-harness review-model review-prompts]} worktree-path prev-feedback]
233
+ (let [;; Get actual diff content (not just stat) — truncate to 8000 chars for prompt budget
234
+ diff-result (process/sh ["git" "diff" "main"]
231
235
  {:dir worktree-path :out :string :err :string})
232
- diff-summary (:out diff-result)
236
+ diff-content (let [d (:out diff-result)]
237
+ (if (> (count d) 8000)
238
+ (str (subs d 0 8000) "\n... [diff truncated at 8000 chars]")
239
+ d))
233
240
 
234
- ;; Build review prompt (tagged for claude-web-view worker detection)
241
+ ;; Build review prompt use custom prompts if configured, else default
235
242
  swarm-id* (or swarm-id "unknown")
236
- review-prompt (str "[oompa:" swarm-id* ":" id "] "
237
- "Review the changes in this worktree.\n\n"
238
- "Diff summary:\n" diff-summary "\n\n"
239
- "Check for:\n"
240
- "- Code correctness\n"
241
- "- Matches the intended task\n"
242
- "- No obvious bugs or issues\n\n"
243
- "Respond with:\n"
244
- "- APPROVED if changes are good\n"
245
- "- NEEDS_CHANGES with bullet points of issues\n"
246
- "- REJECTED if fundamentally wrong")
243
+ custom-prompt (when (seq review-prompts)
244
+ (->> review-prompts
245
+ (map load-prompt)
246
+ (remove nil?)
247
+ (str/join "\n\n")))
248
+
249
+ ;; Include previous review history for multi-round context
250
+ history-block (when (seq prev-feedback)
251
+ (str "\n## Previous Review Rounds\n\n"
252
+ "The worker has already attempted fixes based on earlier feedback. "
253
+ "Do NOT raise new issues — only verify the original issues are resolved.\n\n"
254
+ (->> prev-feedback
255
+ (map-indexed (fn [i fb]
256
+ (str "### Round " (inc i) " feedback:\n" fb)))
257
+ (str/join "\n\n"))
258
+ "\n\n"))
259
+
260
+ review-body (str (or custom-prompt
261
+ (str "Review the changes in this worktree.\n"
262
+ "Focus on architecture and design, not style.\n"))
263
+ "\n\nDiff:\n```\n" diff-content "\n```\n"
264
+ (when history-block history-block)
265
+ "\nYour verdict MUST be on its own line, exactly one of:\n"
266
+ "VERDICT: APPROVED\n"
267
+ "VERDICT: NEEDS_CHANGES\n"
268
+ "VERDICT: REJECTED\n")
269
+ review-prompt (str "[oompa:" swarm-id* ":" id "] " review-body)
247
270
 
248
271
  abs-wt (.getAbsolutePath (io/file worktree-path))
272
+ opencode-attach (opencode-attach-url)
249
273
 
250
274
  ;; Build command — cwd=worktree, no sandbox
251
275
  cmd (case review-harness
@@ -256,7 +280,11 @@
256
280
  review-model (into ["--model" review-model])
257
281
  true (conj "--" review-prompt))
258
282
  :claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
259
- review-model (into ["--model" review-model])))
283
+ review-model (into ["--model" review-model]))
284
+ :opencode (cond-> [(resolve-binary! "opencode") "run"]
285
+ review-model (into ["-m" review-model])
286
+ opencode-attach (into ["--attach" opencode-attach])
287
+ true (conj review-prompt)))
260
288
 
261
289
  ;; Run reviewer — cwd=worktree
262
290
  result (try
@@ -266,27 +294,50 @@
266
294
  (catch Exception e
267
295
  {:exit -1 :out "" :err (.getMessage e)}))
268
296
 
269
- output (:out result)]
270
-
271
- {:verdict (cond
272
- (re-find #"(?i)\bAPPROVED\b" output) :approved
273
- (re-find #"(?i)\bREJECTED\b" output) :rejected
274
- :else :needs-changes)
297
+ output (:out result)
298
+
299
+ ;; Parse verdict — require explicit VERDICT: prefix to avoid false matches
300
+ verdict (cond
301
+ (re-find #"VERDICT:\s*APPROVED" output) :approved
302
+ (re-find #"VERDICT:\s*REJECTED" output) :rejected
303
+ (re-find #"VERDICT:\s*NEEDS_CHANGES" output) :needs-changes
304
+ ;; Fallback to loose matching if reviewer didn't use prefix
305
+ (re-find #"(?i)\bAPPROVED\b" output) :approved
306
+ (re-find #"(?i)\bREJECTED\b" output) :rejected
307
+ :else :needs-changes)]
308
+
309
+ ;; Log reviewer output (truncated) for visibility
310
+ (println (format "[%s] Reviewer verdict: %s" id (name verdict)))
311
+ (let [summary (subs output 0 (min 300 (count output)))]
312
+ (println (format "[%s] Review: %s%s" id summary
313
+ (if (> (count output) 300) "..." ""))))
314
+
315
+ {:verdict verdict
275
316
  :comments (when (not= (:exit result) 0)
276
317
  [(:err result)])
277
318
  :output output}))
278
319
 
279
320
  (defn- run-fix!
280
321
  "Ask worker to fix issues based on reviewer feedback.
322
+ all-feedback: vector of all reviewer outputs so far (accumulated across rounds).
281
323
  Returns {:output string, :exit int}"
282
- [{:keys [id swarm-id harness model]} worktree-path feedback]
324
+ [{:keys [id swarm-id harness model]} worktree-path all-feedback]
283
325
  (let [swarm-id* (or swarm-id "unknown")
326
+ feedback-text (if (> (count all-feedback) 1)
327
+ (str "The reviewer has given feedback across " (count all-feedback) " rounds.\n"
328
+ "Fix ALL outstanding issues:\n\n"
329
+ (->> all-feedback
330
+ (map-indexed (fn [i fb]
331
+ (str "--- Round " (inc i) " ---\n" fb)))
332
+ (str/join "\n\n")))
333
+ (str "The reviewer found issues with your changes:\n\n"
334
+ (first all-feedback)))
284
335
  fix-prompt (str "[oompa:" swarm-id* ":" id "] "
285
- "The reviewer found issues with your changes:\n\n"
286
- feedback "\n\n"
287
- "Please fix these issues in the worktree.")
336
+ feedback-text "\n\n"
337
+ "Fix these issues. Do not add anything the reviewer did not ask for.")
288
338
 
289
339
  abs-wt (.getAbsolutePath (io/file worktree-path))
340
+ opencode-attach (opencode-attach-url)
290
341
 
291
342
  cmd (case harness
292
343
  :codex (cond-> [(resolve-binary! "codex") "exec"
@@ -296,7 +347,11 @@
296
347
  model (into ["--model" model])
297
348
  true (conj "--" fix-prompt))
298
349
  :claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
299
- model (into ["--model" model])))
350
+ model (into ["--model" model]))
351
+ :opencode (cond-> [(resolve-binary! "opencode") "run"]
352
+ model (into ["-m" model])
353
+ opencode-attach (into ["--attach" opencode-attach])
354
+ true (conj fix-prompt)))
300
355
 
301
356
  result (try
302
357
  (if (= harness :claude)
@@ -309,142 +364,173 @@
309
364
  :exit (:exit result)}))
310
365
 
311
366
  (defn- worktree-has-changes?
312
- "Check if worktree has any uncommitted changes (new/modified/deleted files)."
367
+ "Check if worktree has committed OR uncommitted changes vs main.
368
+ Workers commit before signaling merge, so we must check both:
369
+ 1. Uncommitted changes (git status --porcelain)
370
+ 2. Commits ahead of main (git rev-list --count main..HEAD)"
313
371
  [wt-path]
314
- (let [result (process/sh ["git" "status" "--porcelain"] {:dir wt-path :out :string :err :string})]
315
- (not (str/blank? (:out result)))))
372
+ (let [uncommitted (process/sh ["git" "status" "--porcelain"]
373
+ {:dir wt-path :out :string :err :string})
374
+ ahead (process/sh ["git" "rev-list" "--count" "main..HEAD"]
375
+ {:dir wt-path :out :string :err :string})
376
+ ahead-count (try (Integer/parseInt (str/trim (:out ahead)))
377
+ (catch Exception _ 0))]
378
+ (or (not (str/blank? (:out uncommitted)))
379
+ (pos? ahead-count))))
380
+
381
+ (defn- create-iteration-worktree!
382
+ "Create a fresh worktree for an iteration. Returns {:dir :branch :path}.
383
+ Force-removes stale worktree+branch from previous failed runs first."
384
+ [project-root worker-id iteration]
385
+ (let [wt-dir (format ".w%s-i%d" worker-id iteration)
386
+ wt-branch (format "oompa/%s-i%d" worker-id iteration)
387
+ wt-path (str project-root "/" wt-dir)]
388
+ ;; Clean stale worktree/branch from previous failed runs
389
+ (process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
390
+ (process/sh ["git" "branch" "-D" wt-branch] {:dir project-root})
391
+ (let [result (process/sh ["git" "worktree" "add" wt-dir "-b" wt-branch]
392
+ {:dir project-root :out :string :err :string})]
393
+ (when-not (zero? (:exit result))
394
+ (throw (ex-info (str "Failed to create worktree: " (:err result))
395
+ {:dir wt-dir :branch wt-branch}))))
396
+ {:dir wt-dir :branch wt-branch :path wt-path}))
397
+
398
+ (defn- cleanup-worktree!
399
+ "Remove worktree and branch."
400
+ [project-root wt-dir wt-branch]
401
+ (process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
402
+ (process/sh ["git" "branch" "-D" wt-branch] {:dir project-root}))
403
+
404
+ (defn- get-head-hash
405
+ "Get the short HEAD commit hash."
406
+ [dir]
407
+ (let [result (process/sh ["git" "rev-parse" "--short" "HEAD"]
408
+ {:dir dir :out :string :err :string})]
409
+ (when (zero? (:exit result))
410
+ (str/trim (:out result)))))
411
+
412
+ (defn- annotate-completed-tasks!
413
+ "After a successful merge (called under merge-lock), annotate any tasks in
414
+ complete/ that lack metadata. Adds :completed-by, :completed-at,
415
+ :review-rounds, :merged-commit."
416
+ [project-root worker-id review-rounds]
417
+ (let [commit-hash (get-head-hash project-root)
418
+ complete-dir (io/file project-root "tasks" "complete")]
419
+ (when (.exists complete-dir)
420
+ (doseq [f (.listFiles complete-dir)]
421
+ (when (str/ends-with? (.getName f) ".edn")
422
+ (try
423
+ (let [task (read-string (slurp f))]
424
+ (when-not (:completed-by task)
425
+ (spit f (pr-str (assoc task
426
+ :completed-by worker-id
427
+ :completed-at (str (java.time.Instant/now))
428
+ :review-rounds (or review-rounds 0)
429
+ :merged-commit (or commit-hash "unknown"))))))
430
+ (catch Exception e
431
+ (println (format "[%s] Failed to annotate task %s: %s"
432
+ worker-id (.getName f) (.getMessage e))))))))))
316
433
 
317
434
  (defn- merge-to-main!
318
- "Merge worktree changes to main branch"
319
- [wt-path wt-id worker-id project-root]
320
- (println (format "[%s] Merging changes to main" worker-id))
321
- (let [;; Commit in worktree if needed
322
- _ (process/sh ["git" "add" "-A"] {:dir wt-path})
323
- _ (process/sh ["git" "commit" "-m" (str "Work from " wt-id)]
324
- {:dir wt-path})
325
- ;; Checkout main and merge (in project root, not worktree)
326
- checkout-result (process/sh ["git" "checkout" "main"]
327
- {:dir project-root :out :string :err :string})
328
- merge-result (when (zero? (:exit checkout-result))
329
- (process/sh ["git" "merge" wt-id "--no-edit"]
330
- {:dir project-root :out :string :err :string}))]
331
- (and (zero? (:exit checkout-result))
332
- (zero? (:exit merge-result)))))
435
+ "Merge worktree changes to main branch. Serialized via merge-lock to prevent
436
+ concurrent workers from corrupting the git index. On success, annotates any
437
+ newly-completed tasks with worker metadata. Returns true on success.
438
+ review-rounds: number of review rounds (0 for auto-merged task-only changes)."
439
+ [wt-path wt-id worker-id project-root review-rounds]
440
+ (locking merge-lock
441
+ (println (format "[%s] Merging changes to main" worker-id))
442
+ (let [;; Commit in worktree if needed (no-op if already committed)
443
+ _ (process/sh ["git" "add" "-A"] {:dir wt-path})
444
+ _ (process/sh ["git" "commit" "-m" (str "Work from " wt-id)]
445
+ {:dir wt-path})
446
+ ;; Checkout main and merge (in project root, not worktree)
447
+ checkout-result (process/sh ["git" "checkout" "main"]
448
+ {:dir project-root :out :string :err :string})
449
+ _ (when-not (zero? (:exit checkout-result))
450
+ (println (format "[%s] MERGE FAILED: could not checkout main: %s"
451
+ worker-id (:err checkout-result))))
452
+ merge-result (when (zero? (:exit checkout-result))
453
+ (process/sh ["git" "merge" wt-id "--no-edit"]
454
+ {:dir project-root :out :string :err :string}))
455
+ success (and (zero? (:exit checkout-result))
456
+ (zero? (:exit merge-result)))]
457
+ (if success
458
+ (do
459
+ (println (format "[%s] Merge successful" worker-id))
460
+ ;; Annotate completed tasks while still holding merge-lock
461
+ (annotate-completed-tasks! project-root worker-id review-rounds))
462
+ (when merge-result
463
+ (println (format "[%s] MERGE FAILED: %s" worker-id (:err merge-result)))))
464
+ success)))
465
+
466
+ (defn- task-only-diff?
467
+ "Check if all changes in worktree are task files only (no code changes).
468
+ Returns true if diff only touches files under tasks/ directory."
469
+ [wt-path]
470
+ (let [result (process/sh ["git" "diff" "main" "--name-only"]
471
+ {:dir wt-path :out :string :err :string})
472
+ files (when (zero? (:exit result))
473
+ (->> (str/split-lines (:out result))
474
+ (remove str/blank?)))]
475
+ (and (seq files)
476
+ (every? #(str/starts-with? % "tasks/") files))))
477
+
478
+ (defn- diff-file-names
479
+ "Get list of changed file names vs main."
480
+ [wt-path]
481
+ (let [result (process/sh ["git" "diff" "main" "--name-only"]
482
+ {:dir wt-path :out :string :err :string})]
483
+ (when (zero? (:exit result))
484
+ (->> (str/split-lines (:out result))
485
+ (remove str/blank?)
486
+ vec))))
333
487
 
334
488
  (defn- review-loop!
335
489
  "Run review loop: reviewer checks → if issues, fix & retry → back to reviewer.
490
+ Accumulates feedback across rounds so reviewer doesn't raise new issues
491
+ and fixer has full context of all prior feedback.
492
+ Writes review logs to runs/{swarm-id}/reviews/ for post-mortem analysis.
336
493
  Returns {:approved? bool, :attempts int}"
337
- [worker wt-path worker-id]
494
+ [worker wt-path worker-id iteration]
338
495
  (if-not (and (:review-harness worker) (:review-model worker))
339
496
  ;; No reviewer configured, auto-approve
340
497
  {:approved? true :attempts 0}
341
498
 
342
- ;; Run review loop
343
- (loop [attempt 1]
499
+ ;; Run review loop with accumulated feedback
500
+ (loop [attempt 1
501
+ prev-feedback []]
344
502
  (println (format "[%s] Review attempt %d/%d" worker-id attempt max-review-retries))
345
- (let [{:keys [verdict output]} (run-reviewer! worker wt-path)]
503
+ (let [{:keys [verdict output]} (run-reviewer! worker wt-path prev-feedback)
504
+ diff-files (diff-file-names wt-path)]
505
+
506
+ ;; Persist review log for this round
507
+ (when (:swarm-id worker)
508
+ (runs/write-review-log! (:swarm-id worker) worker-id iteration attempt
509
+ {:verdict verdict
510
+ :output output
511
+ :diff-files (or diff-files [])}))
512
+
346
513
  (case verdict
347
514
  :approved
348
515
  (do
349
- (println (format "[%s] Reviewer APPROVED" worker-id))
516
+ (println (format "[%s] Reviewer APPROVED (attempt %d)" worker-id attempt))
350
517
  {:approved? true :attempts attempt})
351
518
 
352
519
  :rejected
353
520
  (do
354
- (println (format "[%s] Reviewer REJECTED" worker-id))
521
+ (println (format "[%s] Reviewer REJECTED (attempt %d)" worker-id attempt))
355
522
  {:approved? false :attempts attempt})
356
523
 
357
524
  ;; :needs-changes
358
- (if (>= attempt max-review-retries)
359
- (do
360
- (println (format "[%s] Max review retries reached" worker-id))
361
- {:approved? false :attempts attempt})
362
- (do
363
- (println (format "[%s] Reviewer requested changes, fixing..." worker-id))
364
- (run-fix! worker wt-path output)
365
- (recur (inc attempt)))))))))
366
-
367
- (defn execute-iteration!
368
- "Execute one iteration of work.
369
-
370
- Flow:
371
- 1. Create worktree
372
- 2. Run agent
373
- 3. If reviewer configured: run review loop (fix → retry → reviewer)
374
- 4. If approved: merge to main
375
- 5. Cleanup worktree
376
-
377
- Returns {:status :done|:continue|:error, :task task-or-nil}"
378
- [worker iteration total-iterations]
379
- (let [worker-id (:id worker)
380
- project-root (System/getProperty "user.dir")
381
- wt-dir (format ".w%s-i%d" worker-id iteration)
382
- wt-branch (format "oompa/%s-i%d" worker-id iteration)
383
-
384
- ;; Create worktree
385
- _ (println (format "[%s] Starting iteration %d/%d" worker-id iteration total-iterations))
386
- wt-path (str project-root "/" wt-dir)]
387
-
388
- (try
389
- ;; Clean stale worktree/branch from previous failed runs before creating
390
- (process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
391
- (process/sh ["git" "branch" "-D" wt-branch] {:dir project-root})
392
-
393
- ;; Setup worktree (in project root)
394
- (let [wt-result (process/sh ["git" "worktree" "add" wt-dir "-b" wt-branch]
395
- {:dir project-root :out :string :err :string})]
396
- (when-not (zero? (:exit wt-result))
397
- (throw (ex-info (str "Failed to create worktree: " (:err wt-result))
398
- {:dir wt-dir :branch wt-branch}))))
399
-
400
- ;; Build context
401
- (let [context (build-context)
402
-
403
- ;; Run agent
404
- {:keys [output exit done?]} (run-agent! worker wt-path context)]
405
-
406
- (cond
407
- ;; Agent signaled done — only honor for can_plan workers.
408
- ;; Executors (can_plan: false) can't decide work is done.
409
- (and done? (:can-plan worker))
410
- (do
411
- (println (format "[%s] Received __DONE__ signal" worker-id))
412
- {:status :done})
413
-
414
- ;; can_plan: false worker said __DONE__ — ignore it, treat as success
415
- (and done? (not (:can-plan worker)))
416
- (do
417
- (println (format "[%s] Ignoring __DONE__ (executor cannot signal done)" worker-id))
418
- {:status :continue})
419
-
420
- ;; Agent errored
421
- (not (zero? exit))
422
- (do
423
- (println (format "[%s] Agent error (exit %d): %s" worker-id exit (subs (or output "") 0 (min 200 (count (or output ""))))))
424
- {:status :error :exit exit})
425
-
426
- ;; Agent produced no file changes — wasted iteration
427
- (not (worktree-has-changes? wt-path))
428
- (do
429
- (println (format "[%s] No changes produced, skipping review" worker-id))
430
- {:status :no-changes})
431
-
432
- ;; Success with changes - run review loop before merge
433
- :else
434
- (let [{:keys [approved?]} (review-loop! worker wt-path worker-id)]
435
- (if approved?
525
+ (let [all-feedback (conj prev-feedback output)]
526
+ (if (>= attempt max-review-retries)
436
527
  (do
437
- (merge-to-main! wt-path wt-branch worker-id project-root)
438
- (println (format "[%s] Iteration %d/%d complete" worker-id iteration total-iterations))
439
- {:status :continue})
528
+ (println (format "[%s] Max review retries reached (%d rounds)" worker-id attempt))
529
+ {:approved? false :attempts attempt})
440
530
  (do
441
- (println (format "[%s] Iteration %d/%d rejected, discarding" worker-id iteration total-iterations))
442
- {:status :continue})))))
443
-
444
- (finally
445
- ;; Cleanup worktree and branch (in project root)
446
- (process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
447
- (process/sh ["git" "branch" "-D" wt-branch] {:dir project-root})))))
531
+ (println (format "[%s] Reviewer requested changes, fixing..." worker-id))
532
+ (run-fix! worker wt-path all-feedback)
533
+ (recur (inc attempt) all-feedback)))))))))
448
534
 
449
535
  ;; =============================================================================
450
536
  ;; Worker Loop
@@ -472,12 +558,18 @@
472
558
  (recur (+ waited wait-poll-interval))))))
473
559
 
474
560
  (defn run-worker!
475
- "Run worker loop until done or iterations exhausted.
561
+ "Run worker loop with persistent sessions.
476
562
 
477
- Returns final worker state."
563
+ Sessions persist across iterations — agents resume where they left off.
564
+ Worktrees persist until COMPLETE_AND_READY_FOR_MERGE triggers review+merge.
565
+ __DONE__ stops the worker entirely (planners only).
566
+
567
+ Tracks per-worker metrics: merges, rejections, errors, review-rounds-total.
568
+ Returns final worker state with metrics attached."
478
569
  [worker]
479
570
  (tasks/ensure-dirs!)
480
- (let [{:keys [id iterations]} worker]
571
+ (let [{:keys [id iterations]} worker
572
+ project-root (System/getProperty "user.dir")]
481
573
  (println (format "[%s] Starting worker (%s:%s%s, %d iterations)"
482
574
  id
483
575
  (name (:harness worker))
@@ -489,37 +581,138 @@
489
581
  (when-not (:can-plan worker)
490
582
  (wait-for-tasks! id))
491
583
 
584
+ ;; metrics tracks: {:merges N :rejections N :errors N :review-rounds-total N}
492
585
  (loop [iter 1
493
586
  completed 0
494
- consec-errors 0]
495
- (if (> iter iterations)
496
- (do
497
- (println (format "[%s] Completed %d iterations" id completed))
498
- (assoc worker :completed completed :status :exhausted))
499
-
500
- (let [{:keys [status]} (execute-iteration! worker iter iterations)]
501
- (case status
502
- :done
503
- (do
504
- (println (format "[%s] Worker done after %d/%d iterations" id iter iterations))
505
- (assoc worker :completed iter :status :done))
506
-
507
- :error
508
- (let [errors (inc consec-errors)]
509
- (if (>= errors max-consecutive-errors)
510
- (do
511
- (println (format "[%s] %d consecutive errors, stopping worker" id errors))
512
- (assoc worker :completed completed :status :error))
513
- (do
514
- (println (format "[%s] Error at iteration %d/%d (%d/%d), continuing..."
515
- id iter iterations errors max-consecutive-errors))
516
- (recur (inc iter) completed errors))))
517
-
518
- :no-changes
519
- (recur (inc iter) completed consec-errors)
520
-
521
- :continue
522
- (recur (inc iter) (inc completed) 0)))))))
587
+ consec-errors 0
588
+ metrics {:merges 0 :rejections 0 :errors 0 :review-rounds-total 0}
589
+ session-id nil ;; persistent session-id (nil = start fresh)
590
+ wt-state nil] ;; {:dir :branch :path} or nil
591
+ (let [finish (fn [status]
592
+ (assoc worker :completed completed :status status
593
+ :merges (:merges metrics)
594
+ :rejections (:rejections metrics)
595
+ :errors (:errors metrics)
596
+ :review-rounds-total (:review-rounds-total metrics)))]
597
+ (if (> iter iterations)
598
+ (do
599
+ ;; Cleanup any lingering worktree
600
+ (when wt-state
601
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state)))
602
+ (println (format "[%s] Completed %d iterations (%d merges, %d rejections, %d errors)"
603
+ id completed (:merges metrics) (:rejections metrics) (:errors metrics)))
604
+ (finish :exhausted))
605
+
606
+ ;; Ensure worktree exists (create fresh if nil, reuse if persisted)
607
+ (let [wt-state (try
608
+ (or wt-state (create-iteration-worktree! project-root id iter))
609
+ (catch Exception e
610
+ (println (format "[%s] Worktree creation failed: %s" id (.getMessage e)))
611
+ nil))]
612
+ (if (nil? wt-state)
613
+ ;; Worktree creation failed — count as error
614
+ (let [errors (inc consec-errors)
615
+ metrics (update metrics :errors inc)]
616
+ (if (>= errors max-consecutive-errors)
617
+ (do
618
+ (println (format "[%s] %d consecutive errors, stopping" id errors))
619
+ (finish :error))
620
+ (recur (inc iter) completed errors metrics nil nil)))
621
+
622
+ ;; Worktree ready — run agent
623
+ (let [resume? (some? session-id)
624
+ _ (println (format "[%s] %s iteration %d/%d"
625
+ id (if resume? "Resuming" "Starting") iter iterations))
626
+ context (build-context)
627
+ {:keys [output exit done? merge?] :as agent-result}
628
+ (run-agent! worker (:path wt-state) context session-id resume?)
629
+ new-session-id (:session-id agent-result)]
630
+
631
+ (cond
632
+ ;; Agent errored — cleanup, reset session
633
+ (not (zero? exit))
634
+ (let [errors (inc consec-errors)
635
+ metrics (update metrics :errors inc)]
636
+ (println (format "[%s] Agent error (exit %d): %s"
637
+ id exit (subs (or output "") 0 (min 200 (count (or output ""))))))
638
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
639
+ (if (>= errors max-consecutive-errors)
640
+ (do
641
+ (println (format "[%s] %d consecutive errors, stopping" id errors))
642
+ (finish :error))
643
+ (recur (inc iter) completed errors metrics nil nil)))
644
+
645
+ ;; COMPLETE_AND_READY_FOR_MERGE — review, merge, reset session
646
+ merge?
647
+ (if (worktree-has-changes? (:path wt-state))
648
+ (if (task-only-diff? (:path wt-state))
649
+ ;; Task-only changes — skip review, auto-merge
650
+ (do
651
+ (println (format "[%s] Task-only diff, auto-merging" id))
652
+ (let [merged? (merge-to-main! (:path wt-state) (:branch wt-state) id project-root 0)
653
+ metrics (if merged? (update metrics :merges inc) metrics)]
654
+ (println (format "[%s] Iteration %d/%d complete" id iter iterations))
655
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
656
+ (if (and done? (:can-plan worker))
657
+ (do
658
+ (println (format "[%s] Worker done after merge" id))
659
+ (assoc worker :completed (inc completed) :status :done
660
+ :merges (:merges metrics)
661
+ :rejections (:rejections metrics)
662
+ :errors (:errors metrics)
663
+ :review-rounds-total (:review-rounds-total metrics)))
664
+ (recur (inc iter) (inc completed) 0 metrics nil nil))))
665
+ ;; Code changes — full review loop
666
+ (let [{:keys [approved? attempts]} (review-loop! worker (:path wt-state) id iter)
667
+ metrics (-> metrics
668
+ (update :review-rounds-total + (or attempts 0))
669
+ (update (if approved? :merges :rejections) inc))]
670
+ (if approved?
671
+ (do
672
+ (merge-to-main! (:path wt-state) (:branch wt-state) id project-root (or attempts 0))
673
+ (println (format "[%s] Iteration %d/%d complete" id iter iterations))
674
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
675
+ ;; If also __DONE__, stop after merge
676
+ (if (and done? (:can-plan worker))
677
+ (do
678
+ (println (format "[%s] Worker done after merge" id))
679
+ (assoc worker :completed (inc completed) :status :done
680
+ :merges (:merges metrics)
681
+ :rejections (:rejections metrics)
682
+ :errors (:errors metrics)
683
+ :review-rounds-total (:review-rounds-total metrics)))
684
+ (recur (inc iter) (inc completed) 0 metrics nil nil)))
685
+ (do
686
+ (println (format "[%s] Iteration %d/%d rejected" id iter iterations))
687
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
688
+ (recur (inc iter) completed 0 metrics nil nil)))))
689
+ (do
690
+ (println (format "[%s] Merge signaled but no changes, skipping" id))
691
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
692
+ (recur (inc iter) completed 0 metrics nil nil)))
693
+
694
+ ;; __DONE__ without merge — only honor for planners
695
+ (and done? (:can-plan worker))
696
+ (do
697
+ (println (format "[%s] Received __DONE__ signal" id))
698
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
699
+ (println (format "[%s] Worker done after %d/%d iterations" id iter iterations))
700
+ (finish :done))
701
+
702
+ ;; __DONE__ from executor — ignore signal, but reset session since
703
+ ;; the agent process exited. Resuming a dead session causes exit 1
704
+ ;; which cascades into consecutive errors and premature stopping.
705
+ (and done? (not (:can-plan worker)))
706
+ (do
707
+ (println (format "[%s] Ignoring __DONE__ (executor), resetting session" id))
708
+ (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
709
+ (recur (inc iter) completed 0 metrics nil nil))
710
+
711
+ ;; No signal — agent still working, resume next iteration
712
+ :else
713
+ (do
714
+ (println (format "[%s] Working... (will resume)" id))
715
+ (recur (inc iter) completed 0 metrics new-session-id wt-state)))))))))))
523
716
 
524
717
  ;; =============================================================================
525
718
  ;; Multi-Worker Execution
@@ -527,6 +720,7 @@
527
720
 
528
721
  (defn run-workers!
529
722
  "Run multiple workers in parallel.
723
+ Writes swarm summary to runs/{swarm-id}/summary.edn on completion.
530
724
 
531
725
  Arguments:
532
726
  workers - seq of worker configs
@@ -534,21 +728,106 @@
534
728
  Returns seq of final worker states."
535
729
  [workers]
536
730
  (tasks/ensure-dirs!)
537
- (println (format "Launching %d workers..." (count workers)))
538
-
539
- (let [futures (doall
540
- (map-indexed
541
- (fn [idx worker]
542
- (let [worker (assoc worker :id (or (:id worker) (str "w" idx)))]
543
- (future (run-worker! worker))))
544
- workers))]
545
-
546
- (println "All workers launched. Waiting for completion...")
547
- (let [results (mapv deref futures)]
548
- (println "\nAll workers complete.")
549
- (doseq [w results]
550
- (println (format " [%s] %s - %d iterations"
551
- (:id w)
552
- (name (:status w))
553
- (:completed w))))
554
- results)))
731
+ (let [swarm-id (-> workers first :swarm-id)]
732
+ (println (format "Launching %d workers..." (count workers)))
733
+
734
+ (let [futures (doall
735
+ (map-indexed
736
+ (fn [idx worker]
737
+ (let [worker (assoc worker :id (or (:id worker) (str "w" idx)))]
738
+ (future (run-worker! worker))))
739
+ workers))]
740
+
741
+ (println "All workers launched. Waiting for completion...")
742
+ (let [results (mapv deref futures)]
743
+ (println "\nAll workers complete.")
744
+ (doseq [w results]
745
+ (println (format " [%s] %s - %d completed, %d merges, %d rejections, %d errors, %d review rounds"
746
+ (:id w)
747
+ (name (:status w))
748
+ (:completed w)
749
+ (or (:merges w) 0)
750
+ (or (:rejections w) 0)
751
+ (or (:errors w) 0)
752
+ (or (:review-rounds-total w) 0))))
753
+
754
+ ;; Write swarm summary to disk
755
+ (when swarm-id
756
+ (runs/write-summary! swarm-id results)
757
+ (println (format "\nSwarm summary written to runs/%s/summary.edn" swarm-id)))
758
+
759
+ results))))
760
+
761
+ ;; =============================================================================
762
+ ;; Planner — first-class config concept, NOT a worker
763
+ ;; =============================================================================
764
+ ;; The planner creates task EDN files in tasks/pending/.
765
+ ;; It runs in the project root (no worktree), has no review/merge cycle,
766
+ ;; and respects max_pending backpressure to avoid flooding the queue.
767
+
768
+ (defn run-planner!
769
+ "Run planner agent to create tasks. No worktree, no review, no merge.
770
+ Runs in project root. Respects max_pending cap.
771
+ Returns {:tasks-created N}"
772
+ [{:keys [harness model prompts max-pending swarm-id]}]
773
+ (tasks/ensure-dirs!)
774
+ (let [project-root (System/getProperty "user.dir")
775
+ pending-before (tasks/pending-count)
776
+ max-pending (or max-pending 10)]
777
+ ;; Backpressure: skip if queue is full
778
+ (if (>= pending-before max-pending)
779
+ (do
780
+ (println (format "[planner] Skipping — %d pending tasks (max: %d)" pending-before max-pending))
781
+ {:tasks-created 0})
782
+ ;; Run agent
783
+ (let [context (build-context)
784
+ prompt-text (str (when (seq prompts)
785
+ (->> prompts
786
+ (map load-prompt)
787
+ (remove nil?)
788
+ (str/join "\n\n")))
789
+ "\n\nTask Status: " (:task_status context) "\n"
790
+ "Pending: " (:pending_tasks context) "\n\n"
791
+ "Create tasks in tasks/pending/ as .edn files.\n"
792
+ "Maximum " (- max-pending pending-before) " new tasks.\n"
793
+ "Signal __DONE__ when finished planning.")
794
+ swarm-id* (or swarm-id "unknown")
795
+ tagged-prompt (str "[oompa:" swarm-id* ":planner] " prompt-text)
796
+ abs-root (.getAbsolutePath (io/file project-root))
797
+ opencode-attach (opencode-attach-url)
798
+
799
+ cmd (case harness
800
+ :codex (cond-> [(resolve-binary! "codex") "exec"
801
+ "--dangerously-bypass-approvals-and-sandbox"
802
+ "--skip-git-repo-check"
803
+ "-C" abs-root]
804
+ model (into ["--model" model])
805
+ true (conj "--" tagged-prompt))
806
+ :claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
807
+ model (into ["--model" model]))
808
+ :opencode (cond-> [(resolve-binary! "opencode") "run"]
809
+ model (into ["-m" model])
810
+ opencode-attach (into ["--attach" opencode-attach])
811
+ true (conj tagged-prompt)))
812
+
813
+ _ (println (format "[planner] Running (%s:%s, max_pending: %d, current: %d)"
814
+ (name harness) (or model "default") max-pending pending-before))
815
+
816
+ result (try
817
+ (if (= harness :claude)
818
+ (process/sh cmd {:dir abs-root :in tagged-prompt :out :string :err :string})
819
+ (process/sh cmd {:dir abs-root :out :string :err :string}))
820
+ (catch Exception e
821
+ (println (format "[planner] Agent exception: %s" (.getMessage e)))
822
+ {:exit -1 :out "" :err (.getMessage e)}))
823
+
824
+ ;; Commit any new task files
825
+ _ (process/sh ["git" "add" "tasks/pending/"] {:dir abs-root})
826
+ _ (process/sh ["git" "commit" "-m" "Planner: add tasks"]
827
+ {:dir abs-root :out :string :err :string})
828
+
829
+ pending-after (tasks/pending-count)
830
+ created (- pending-after pending-before)]
831
+
832
+ (println (format "[planner] Done. Created %d tasks (pending: %d)" created pending-after))
833
+ {:tasks-created created}))))