@nbardy/oompa 0.6.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.
@@ -0,0 +1,217 @@
1
+ (ns agentnet.harness
2
+ "Harness configuration registry.
3
+
4
+ Each harness is a data map describing how to invoke a CLI agent.
5
+ CLI flag syntax is owned by agent-cli (shared with claude-web-view).
6
+ This module owns: stdin behavior, session strategy, output parsing.
7
+
8
+ Harness = Codex | Claude | Opencode | Gemini
9
+ δ(harness) → config map → agent-cli JSON → command vector"
10
+ (:require [babashka.process :as process]
11
+ [cheshire.core :as json]
12
+ [clojure.string :as str]))
13
+
14
+ ;; =============================================================================
15
+ ;; Binary Resolution (shared across all harnesses)
16
+ ;; =============================================================================
17
+
18
+ (def ^:private binary-paths* (atom {}))
19
+
20
+ (defn resolve-binary!
21
+ "Resolve the absolute path of a CLI binary. Caches result.
22
+ Throws if binary not found on PATH.
23
+ ProcessBuilder with :dir can fail to find bare command names on macOS/babashka,
24
+ so we resolve once via `which` and cache."
25
+ [name]
26
+ (or (get @binary-paths* name)
27
+ (let [result (try
28
+ (process/sh ["which" name] {:out :string :err :string})
29
+ (catch Exception _ {:exit -1 :out "" :err ""}))]
30
+ (if (zero? (:exit result))
31
+ (let [path (str/trim (:out result))]
32
+ (swap! binary-paths* assoc name path)
33
+ path)
34
+ (throw (ex-info (str "Binary not found on PATH: " name) {:binary name}))))))
35
+
36
+ ;; =============================================================================
37
+ ;; Opencode Output Parsing (harness-specific, lives here not in agent-cli)
38
+ ;; =============================================================================
39
+
40
+ (defn- parse-opencode-run-output
41
+ "Parse `opencode run --format json` NDJSON output.
42
+ Returns {:session-id string|nil, :text string|nil}."
43
+ [s]
44
+ (let [raw (or s "")
45
+ events (->> (str/split-lines raw)
46
+ (keep (fn [line]
47
+ (try
48
+ (json/parse-string line true)
49
+ (catch Exception _
50
+ nil))))
51
+ doall)
52
+ session-id (or (some #(or (:sessionID %)
53
+ (:sessionId %)
54
+ (get-in % [:part :sessionID])
55
+ (get-in % [:part :sessionId]))
56
+ events)
57
+ (some-> (re-find #"(ses_[A-Za-z0-9]+)" raw) second))
58
+ text (->> events
59
+ (keep (fn [event]
60
+ (let [event-type (or (:type event) (get-in event [:part :type]))
61
+ chunk (or (:text event) (get-in event [:part :text]))]
62
+ (when (and (= event-type "text")
63
+ (string? chunk)
64
+ (not (str/blank? chunk)))
65
+ chunk))))
66
+ (str/join ""))]
67
+ {:session-id session-id
68
+ :text (when-not (str/blank? text) text)}))
69
+
70
+ ;; =============================================================================
71
+ ;; Env Helpers
72
+ ;; =============================================================================
73
+
74
+ (defn- opencode-attach-url
75
+ "Optional opencode server URL for run --attach mode."
76
+ []
77
+ (let [url (or (System/getenv "OOMPA_OPENCODE_ATTACH")
78
+ (System/getenv "OPENCODE_ATTACH"))]
79
+ (when (and url (not (str/blank? url)))
80
+ url)))
81
+
82
+ ;; =============================================================================
83
+ ;; Harness Registry — oompa-specific behavior fields only
84
+ ;; =============================================================================
85
+ ;;
86
+ ;; CLI flag syntax (model flags, session flags, bypass, prompt delivery)
87
+ ;; is owned by agent-cli. This registry only tracks:
88
+ ;;
89
+ ;; :stdin - what to pass as process stdin (:close or :prompt)
90
+ ;; :session - session ID strategy (:uuid, :extracted, :implicit)
91
+ ;; :output - output format (:plain or :ndjson)
92
+
93
+ (def registry
94
+ {:codex {:stdin :close :session :uuid :output :plain}
95
+ :claude {:stdin :prompt :session :uuid :output :plain}
96
+ :opencode {:stdin :close :session :extracted :output :ndjson}
97
+ :gemini {:stdin :close :session :implicit :output :plain}})
98
+
99
+ ;; =============================================================================
100
+ ;; Registry Access
101
+ ;; =============================================================================
102
+
103
+ (defn get-config
104
+ "Look up harness config. Throws on unknown harness (no silent fallback)."
105
+ [harness-kw]
106
+ (or (get registry harness-kw)
107
+ (throw (ex-info (str "Unknown harness: " harness-kw
108
+ ". Known: " (str/join ", " (map name (keys registry))))
109
+ {:harness harness-kw}))))
110
+
111
+ (defn known-harnesses
112
+ "Set of all registered harness keywords."
113
+ []
114
+ (set (keys registry)))
115
+
116
+ ;; =============================================================================
117
+ ;; Command Builder — delegates to agent-cli via JSON input
118
+ ;; =============================================================================
119
+
120
+ (defn- add-extra-args
121
+ "Attach harness-specific extraArgs to the JSON input map.
122
+ Only opencode needs extra flags (--format json, --attach)."
123
+ [m harness-kw opts]
124
+ (if (= harness-kw :opencode)
125
+ (let [url (opencode-attach-url)
126
+ extra (cond-> []
127
+ (:format? opts) (into ["--format" "json" "--print-logs" "--log-level" "WARN"])
128
+ url (into ["--attach" url]))]
129
+ (if (seq extra) (assoc m :extraArgs extra) m))
130
+ m))
131
+
132
+ (defn build-cmd
133
+ "Build CLI command vector via agent-cli JSON input.
134
+ Sends a JSON dict to `agent-cli build --input -`, parses the CommandSpec,
135
+ and resolves the binary to an absolute path.
136
+
137
+ agent-cli owns all CLI flag syntax (session create/resume, model decomposition,
138
+ bypass flags, prompt delivery). This function just maps oompa opts to JSON."
139
+ [harness-kw opts]
140
+ (let [input (-> {:harness (name harness-kw)
141
+ :bypassPermissions true}
142
+ (cond->
143
+ (:model opts) (assoc :model (:model opts))
144
+ (:prompt opts) (assoc :prompt (:prompt opts))
145
+ (:session-id opts) (assoc :sessionId (:session-id opts))
146
+ (:resume? opts) (assoc :resume true)
147
+ (:cwd opts) (assoc :cwd (:cwd opts))
148
+ (:reasoning opts) (assoc :reasoning (:reasoning opts)))
149
+ (add-extra-args harness-kw opts)
150
+ json/generate-string)
151
+ {:keys [exit out err]} (process/sh ["agent-cli" "build" "--input" "-"]
152
+ {:in input :out :string :err :string})]
153
+ (when-not (zero? exit)
154
+ (throw (ex-info (str "agent-cli: " (str/trim err)) {:exit exit})))
155
+ (let [argv (:argv (json/parse-string out true))]
156
+ (vec (cons (resolve-binary! (first argv)) (rest argv))))))
157
+
158
+ (defn process-stdin
159
+ "Return the :in value for process/sh.
160
+ :close → \"\" (close stdin immediately to prevent hang).
161
+ :prompt → the prompt text (claude delivers prompt via stdin)."
162
+ [harness-kw prompt]
163
+ (let [{:keys [stdin]} (get-config harness-kw)]
164
+ (case stdin
165
+ :prompt prompt
166
+ :close "")))
167
+
168
+ ;; =============================================================================
169
+ ;; Session Management
170
+ ;; =============================================================================
171
+
172
+ (defn make-session-id
173
+ "Generate initial session-id based on harness strategy.
174
+ :uuid → random UUID string.
175
+ :extracted → nil (will be parsed from output after first run).
176
+ :implicit → nil (harness manages sessions by cwd, e.g. gemini)."
177
+ [harness-kw]
178
+ (let [{:keys [session]} (get-config harness-kw)]
179
+ (when (= session :uuid)
180
+ (str/lower-case (str (java.util.UUID/randomUUID))))))
181
+
182
+ ;; =============================================================================
183
+ ;; Output Parsing
184
+ ;; =============================================================================
185
+
186
+ (defn parse-output
187
+ "Parse agent output. For :ndjson harnesses, extracts session-id and text.
188
+ For :plain, returns output as-is.
189
+ Returns {:output string, :session-id string}."
190
+ [harness-kw raw-output session-id]
191
+ (let [{:keys [output]} (get-config harness-kw)]
192
+ (if (= output :ndjson)
193
+ (let [parsed (parse-opencode-run-output raw-output)]
194
+ {:output (or (:text parsed) raw-output)
195
+ :session-id (or (:session-id parsed) session-id)})
196
+ {:output raw-output
197
+ :session-id session-id})))
198
+
199
+ ;; =============================================================================
200
+ ;; Probe / Health Check — delegates to agent-cli check
201
+ ;; =============================================================================
202
+
203
+ (defn check-available
204
+ "Check if harness CLI binary is available on PATH via agent-cli."
205
+ [harness-kw]
206
+ (try
207
+ (let [{:keys [exit out]} (process/sh ["agent-cli" "check" (name harness-kw)]
208
+ {:out :string :err :string})]
209
+ (when (zero? exit)
210
+ (:available (json/parse-string out true))))
211
+ (catch Exception _ false)))
212
+
213
+ (defn build-probe-cmd
214
+ "Build minimal command to test if a harness+model is accessible.
215
+ Delegates to build-cmd with a '[_HIDE_TEST_] say ok' probe prompt."
216
+ [harness-kw model]
217
+ (build-cmd harness-kw {:model model :prompt "[_HIDE_TEST_] say ok"}))
@@ -286,6 +286,8 @@
286
286
  (defn- ensure-dir! [path]
287
287
  (.mkdirs (io/file path)))
288
288
 
289
+ ;; DEAD CODE: save-run-log!, run-once!, run-loop! are unreachable since
290
+ ;; cmd-run/cmd-loop simple-mode was retired 2026-02-17. Delete in a future pass.
289
291
  (defn save-run-log!
290
292
  "Save run results to JSONL file"
291
293
  [state]
@@ -325,7 +327,7 @@
325
327
  "Convenience: create orchestrator, run, save log, shutdown.
326
328
 
327
329
  Arguments:
328
- opts - {:workers N, :harness :codex|:claude, :model string,
330
+ opts - {:workers N, :harness :codex|:claude|:opencode, :model string,
329
331
  :review-harness keyword, :review-model string,
330
332
  :custom-prompt string, :dry-run bool}
331
333
 
@@ -0,0 +1,190 @@
1
+ (ns agentnet.runs
2
+ "Structured persistence for swarm runs — event-sourced immutable logs.
3
+
4
+ Layout:
5
+ runs/{swarm-id}/
6
+ started.json — swarm start event (PID, worker configs, planner)
7
+ stopped.json — swarm stop event (reason, optional error)
8
+ cycles/
9
+ {worker-id}-c{N}.json — per-cycle event log (one complete work unit)
10
+ reviews/
11
+ {worker-id}-c{N}-r{round}.json — per-cycle review log
12
+
13
+ All state is derived by reading event logs. No mutable summary files.
14
+ Written as JSON for easy consumption by claude-web-view and CLI.
15
+ All writes are atomic (write to .tmp, rename) to avoid partial reads."
16
+ (:require [clojure.java.io :as io]
17
+ [clojure.string :as str]
18
+ [cheshire.core :as json]))
19
+
20
+ ;; =============================================================================
21
+ ;; Paths
22
+ ;; =============================================================================
23
+
24
+ (def ^:const RUNS_ROOT "runs")
25
+
26
+ (defn- run-dir [swarm-id]
27
+ (str RUNS_ROOT "/" swarm-id))
28
+
29
+ (defn- reviews-dir [swarm-id]
30
+ (str (run-dir swarm-id) "/reviews"))
31
+
32
+ (defn- cycles-dir [swarm-id]
33
+ (str (run-dir swarm-id) "/cycles"))
34
+
35
+ (defn- ensure-run-dirs! [swarm-id]
36
+ (.mkdirs (io/file (reviews-dir swarm-id)))
37
+ (.mkdirs (io/file (cycles-dir swarm-id))))
38
+
39
+ ;; =============================================================================
40
+ ;; Atomic Write
41
+ ;; =============================================================================
42
+
43
+ (defn- write-json!
44
+ "Write JSON data atomically: write to .tmp, rename to final path."
45
+ [path data]
46
+ (let [f (io/file path)
47
+ tmp (io/file (str path ".tmp"))]
48
+ (.mkdirs (.getParentFile f))
49
+ (spit tmp (json/generate-string data {:pretty true}))
50
+ (.renameTo tmp f)))
51
+
52
+ ;; =============================================================================
53
+ ;; Started Event — written at swarm start
54
+ ;; =============================================================================
55
+
56
+ (defn write-started!
57
+ "Write the started event when a swarm begins.
58
+ Contains: start time, PID, worker configs, planner output, swarm metadata."
59
+ [swarm-id {:keys [workers planner-config reviewer-config config-file]}]
60
+ (ensure-run-dirs! swarm-id)
61
+ (write-json! (str (run-dir swarm-id) "/started.json")
62
+ {:swarm-id swarm-id
63
+ :started-at (str (java.time.Instant/now))
64
+ :pid (.pid (java.lang.ProcessHandle/current))
65
+ :config-file config-file
66
+ :workers (mapv (fn [w]
67
+ {:id (:id w)
68
+ :harness (name (:harness w))
69
+ :model (:model w)
70
+ :reasoning (:reasoning w)
71
+ :iterations (:iterations w)
72
+ :can-plan (:can-plan w)
73
+ :prompts (:prompts w)})
74
+ workers)
75
+ :planner (when planner-config
76
+ {:harness (name (:harness planner-config))
77
+ :model (:model planner-config)
78
+ :prompts (:prompts planner-config)
79
+ :max-pending (:max-pending planner-config)})
80
+ :reviewer (when reviewer-config
81
+ {:harness (name (:harness reviewer-config))
82
+ :model (:model reviewer-config)
83
+ :prompts (:prompts reviewer-config)})}))
84
+
85
+ ;; =============================================================================
86
+ ;; Stopped Event — written at swarm end
87
+ ;; =============================================================================
88
+
89
+ (defn write-stopped!
90
+ "Write the stopped event when a swarm exits cleanly."
91
+ [swarm-id reason & {:keys [error]}]
92
+ (write-json! (str (run-dir swarm-id) "/stopped.json")
93
+ {:swarm-id swarm-id
94
+ :stopped-at (str (java.time.Instant/now))
95
+ :reason (name reason)
96
+ :error error}))
97
+
98
+ ;; =============================================================================
99
+ ;; Review Log — written after each review round
100
+ ;; =============================================================================
101
+
102
+ (defn write-review-log!
103
+ "Write a review log for one cycle of a worker.
104
+ Contains: verdict, round number, full reviewer output, diff file list."
105
+ [swarm-id worker-id cycle round
106
+ {:keys [verdict output diff-files]}]
107
+ (ensure-run-dirs! swarm-id)
108
+ (let [filename (format "%s-c%d-r%d.json" worker-id cycle round)]
109
+ (write-json! (str (reviews-dir swarm-id) "/" filename)
110
+ {:worker-id worker-id
111
+ :cycle cycle
112
+ :round round
113
+ :verdict (name verdict)
114
+ :timestamp (str (java.time.Instant/now))
115
+ :output output
116
+ :diff-files (vec diff-files)})))
117
+
118
+ ;; =============================================================================
119
+ ;; Cycle Log — written per cycle for real-time dashboard visibility
120
+ ;; =============================================================================
121
+
122
+ (defn write-cycle-log!
123
+ "Write a cycle event log for a worker.
124
+ Contains: outcome, timing, claimed task IDs, recycled tasks, session-id.
125
+ session-id links to the Claude CLI conversation on disk for debugging.
126
+ Written at cycle end so dashboards can track progress in real-time."
127
+ [swarm-id worker-id cycle
128
+ {:keys [outcome duration-ms claimed-task-ids recycled-tasks
129
+ error-snippet review-rounds session-id]}]
130
+ (when swarm-id
131
+ (let [filename (format "%s-c%d.json" worker-id cycle)]
132
+ (write-json! (str (cycles-dir swarm-id) "/" filename)
133
+ {:worker-id worker-id
134
+ :cycle cycle
135
+ :outcome (name outcome)
136
+ :timestamp (str (java.time.Instant/now))
137
+ :duration-ms duration-ms
138
+ :claimed-task-ids (or claimed-task-ids [])
139
+ :recycled-tasks (or recycled-tasks [])
140
+ :error-snippet error-snippet
141
+ :review-rounds (or review-rounds 0)
142
+ :session-id session-id}))))
143
+
144
+ ;; =============================================================================
145
+ ;; Read helpers (for cmd-status, dashboards)
146
+ ;; =============================================================================
147
+
148
+ (defn list-runs
149
+ "List all swarm run directories, newest first."
150
+ []
151
+ (let [d (io/file RUNS_ROOT)]
152
+ (when (.exists d)
153
+ (->> (.listFiles d)
154
+ (filter #(.isDirectory %))
155
+ (sort-by #(.lastModified %) >)
156
+ (mapv #(.getName %))))))
157
+
158
+ (defn read-started
159
+ "Read started.json for a swarm."
160
+ [swarm-id]
161
+ (let [f (io/file (str (run-dir swarm-id) "/started.json"))]
162
+ (when (.exists f)
163
+ (json/parse-string (slurp f) true))))
164
+
165
+ (defn read-stopped
166
+ "Read stopped.json for a swarm. Returns nil if still running."
167
+ [swarm-id]
168
+ (let [f (io/file (str (run-dir swarm-id) "/stopped.json"))]
169
+ (when (.exists f)
170
+ (json/parse-string (slurp f) true))))
171
+
172
+ (defn list-reviews
173
+ "List all review logs for a swarm, sorted by filename."
174
+ [swarm-id]
175
+ (let [d (io/file (reviews-dir swarm-id))]
176
+ (when (.exists d)
177
+ (->> (.listFiles d)
178
+ (filter #(str/ends-with? (.getName %) ".json"))
179
+ (sort-by #(.getName %))
180
+ (mapv (fn [f] (json/parse-string (slurp f) true)))))))
181
+
182
+ (defn list-cycles
183
+ "List all cycle logs for a swarm, sorted by filename."
184
+ [swarm-id]
185
+ (let [d (io/file (cycles-dir swarm-id))]
186
+ (when (.exists d)
187
+ (->> (.listFiles d)
188
+ (filter #(str/ends-with? (.getName %) ".json"))
189
+ (sort-by #(.getName %))
190
+ (mapv (fn [f] (json/parse-string (slurp f) true)))))))
@@ -40,7 +40,7 @@
40
40
  ;; Enum Validators
41
41
  ;; =============================================================================
42
42
 
43
- (def agent-types #{:codex :claude})
43
+ (def agent-types #{:codex :claude :opencode :gemini})
44
44
  (def agent-roles #{:proposer :reviewer :cto})
45
45
  (def task-statuses #{:pending :in-progress :review :approved :merged :failed :blocked})
46
46
  (def worktree-statuses #{:available :busy :dirty :stale})
@@ -123,7 +123,7 @@
123
123
  ;; =============================================================================
124
124
 
125
125
  ;; TaskId <- non-blank-string?
126
- ;; AgentType <- agent-type? (:codex, :claude)
126
+ ;; AgentType <- agent-type? (:codex, :claude, :opencode)
127
127
  ;; AgentRole <- agent-role? (:proposer, :reviewer, :cto)
128
128
  ;; TaskStatus <- task-status?
129
129
  ;; WorktreeStatus <- worktree-status?
@@ -135,7 +135,7 @@
135
135
  ;;
136
136
  ;; Task <- {:id string, :summary string, :targets [string], ...}
137
137
  ;; Worktree <- {:id string, :path string, :branch string, :status keyword}
138
- ;; AgentConfig <- {:type :codex|:claude, :model string, :sandbox keyword}
138
+ ;; AgentConfig <- {:type :codex|:claude|:opencode, :model string, :sandbox keyword}
139
139
  ;; ReviewFeedback <- {:verdict :approved|:needs-changes|:rejected, :comments [string]}
140
140
  ;; MergeResult <- {:status :merged|:conflict|:failed, :source-branch string, ...}
141
- ;; OrchestratorConfig <- {:worker-count int, :harness :codex|:claude, :model string, :dry-run bool}
141
+ ;; OrchestratorConfig <- {:worker-count int, :harness :codex|:claude|:opencode, :model string, :dry-run bool}
@@ -111,6 +111,7 @@
111
111
  [task]
112
112
  (move-task! task CURRENT_DIR COMPLETE_DIR))
113
113
 
114
+
114
115
  (defn unclaim-task!
115
116
  "Return a task to pending (mv current → pending). Returns task or nil."
116
117
  [task]
@@ -122,6 +123,35 @@
122
123
  (when-let [task (first (list-pending))]
123
124
  (claim-task! task)))
124
125
 
126
+ (defn claim-by-id!
127
+ "Claim a pending task by its :id string. Atomically moves pending → current.
128
+ Returns {:status :claimed|:already-taken|:not-found, :id id}."
129
+ [task-id]
130
+ (let [match (first (filter #(= (:id %) task-id) (list-pending)))]
131
+ (if match
132
+ (if (claim-task! match)
133
+ {:status :claimed :id task-id}
134
+ {:status :already-taken :id task-id})
135
+ ;; Not in pending — check if already in current (raced)
136
+ (if (some #(= (:id %) task-id) (list-current))
137
+ {:status :already-taken :id task-id}
138
+ {:status :not-found :id task-id}))))
139
+
140
+ (defn claim-by-ids!
141
+ "Claim multiple tasks by ID. Returns vector of result maps."
142
+ [task-ids]
143
+ (mapv claim-by-id! task-ids))
144
+
145
+ (defn complete-by-ids!
146
+ "Move tasks from current → complete by ID. Framework-owned completion.
147
+ Returns vector of completed task IDs."
148
+ [task-ids]
149
+ (let [id-set (set task-ids)
150
+ to-complete (filter #(id-set (:id %)) (list-current))]
151
+ (doseq [task to-complete]
152
+ (complete-task! task))
153
+ (mapv :id to-complete)))
154
+
125
155
  ;; =============================================================================
126
156
  ;; Task Creation
127
157
  ;; =============================================================================
@@ -162,6 +192,24 @@
162
192
  (defn current-count [] (count (list-task-files CURRENT_DIR)))
163
193
  (defn complete-count [] (count (list-task-files COMPLETE_DIR)))
164
194
 
195
+ (defn current-task-ids
196
+ "Return a set of task IDs currently in current/."
197
+ []
198
+ (->> (list-task-files CURRENT_DIR)
199
+ (keep read-task-file)
200
+ (map :id)
201
+ set))
202
+
203
+ (defn recycle-tasks!
204
+ "Move specific tasks from current/ back to pending/ by ID.
205
+ Returns vector of recycled task IDs."
206
+ [task-ids]
207
+ (let [current (list-current)
208
+ to-recycle (filter #(task-ids (:id %)) current)]
209
+ (doseq [task to-recycle]
210
+ (unclaim-task! task))
211
+ (mapv :id to-recycle)))
212
+
165
213
  (defn all-complete?
166
214
  "True if no pending or current tasks"
167
215
  []