@nbardy/oompa 0.1.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.
@@ -0,0 +1,436 @@
1
+ (ns agentnet.cli
2
+ "Command-line interface for AgentNet orchestrator.
3
+
4
+ Usage:
5
+ ./swarm.bb run # Run all tasks once
6
+ ./swarm.bb run --workers 4 # With 4 parallel workers
7
+ ./swarm.bb loop 20 --harness claude # 20 iterations with Claude
8
+ ./swarm.bb loop --workers claude:5 codex:4 --iterations 20 # Mixed harnesses
9
+ ./swarm.bb swarm oompa.json # Multi-model from config
10
+ ./swarm.bb prompt \"...\" # Ad-hoc task
11
+ ./swarm.bb status # Show last run
12
+ ./swarm.bb worktrees # List worktree status
13
+ ./swarm.bb cleanup # Remove all worktrees"
14
+ (:require [agentnet.orchestrator :as orchestrator]
15
+ [agentnet.worktree :as worktree]
16
+ [agentnet.worker :as worker]
17
+ [agentnet.tasks :as tasks]
18
+ [agentnet.agent :as agent]
19
+ [clojure.string :as str]
20
+ [clojure.java.io :as io]
21
+ [cheshire.core :as json]))
22
+
23
+ ;; =============================================================================
24
+ ;; Argument Parsing
25
+ ;; =============================================================================
26
+
27
+ (defn- parse-int [s default]
28
+ (try
29
+ (Integer/parseInt s)
30
+ (catch Exception _ default)))
31
+
32
+ (defn- make-swarm-id
33
+ "Generate a short run-level swarm ID."
34
+ []
35
+ (subs (str (java.util.UUID/randomUUID)) 0 8))
36
+
37
+ (defn- parse-worker-spec
38
+ "Parse 'harness:count' into {:harness :claude, :count 5}.
39
+ Throws on invalid format."
40
+ [s]
41
+ (let [[harness count-str] (str/split s #":" 2)
42
+ h (keyword harness)
43
+ cnt (parse-int count-str 0)]
44
+ (when-not (#{:codex :claude} h)
45
+ (throw (ex-info (str "Unknown harness in worker spec: " s ". Use 'codex:N' or 'claude:N'") {})))
46
+ (when (zero? cnt)
47
+ (throw (ex-info (str "Invalid count in worker spec: " s ". Use format 'harness:count'") {})))
48
+ {:harness h :count cnt}))
49
+
50
+ (defn- worker-spec? [s]
51
+ "Check if string looks like 'harness:count' format"
52
+ (and (string? s)
53
+ (not (str/starts-with? s "--"))
54
+ (str/includes? s ":")
55
+ (re-matches #"[a-z]+:\d+" s)))
56
+
57
+ (defn- collect-worker-specs
58
+ "Collect consecutive worker specs from args. Returns [specs remaining-args]."
59
+ [args]
60
+ (loop [specs []
61
+ remaining args]
62
+ (if-let [arg (first remaining)]
63
+ (if (worker-spec? arg)
64
+ (recur (conj specs (parse-worker-spec arg)) (next remaining))
65
+ [specs remaining])
66
+ [specs remaining])))
67
+
68
+ (defn parse-args [args]
69
+ (loop [opts {:workers 2
70
+ :harness :codex
71
+ :model nil
72
+ :dry-run false
73
+ :iterations 1
74
+ :worker-specs nil}
75
+ remaining args]
76
+ (if-let [arg (first remaining)]
77
+ (cond
78
+ ;; --workers can take either N or harness:count specs
79
+ (= arg "--workers")
80
+ (let [next-arg (second remaining)]
81
+ (if (worker-spec? next-arg)
82
+ ;; Collect all worker specs: --workers claude:5 codex:4
83
+ (let [[specs rest] (collect-worker-specs (next remaining))]
84
+ (recur (assoc opts :worker-specs specs) rest))
85
+ ;; Simple count: --workers 4
86
+ (recur (assoc opts :workers (parse-int next-arg 2))
87
+ (nnext remaining))))
88
+
89
+ (= arg "--iterations")
90
+ (recur (assoc opts :iterations (parse-int (second remaining) 1))
91
+ (nnext remaining))
92
+
93
+ (= arg "--harness")
94
+ (let [h (keyword (second remaining))]
95
+ (when-not (#{:codex :claude} h)
96
+ (throw (ex-info (str "Unknown harness: " (second remaining) ". Use 'codex' or 'claude'") {})))
97
+ (recur (assoc opts :harness h)
98
+ (nnext remaining)))
99
+
100
+ (= arg "--model")
101
+ (recur (assoc opts :model (second remaining))
102
+ (nnext remaining))
103
+
104
+ ;; Legacy flags (still supported)
105
+ (= arg "--claude")
106
+ (recur (assoc opts :harness :claude)
107
+ (next remaining))
108
+
109
+ (= arg "--codex")
110
+ (recur (assoc opts :harness :codex)
111
+ (next remaining))
112
+
113
+ (= arg "--dry-run")
114
+ (recur (assoc opts :dry-run true)
115
+ (next remaining))
116
+
117
+ (= arg "--keep-worktrees")
118
+ (recur (assoc opts :keep-worktrees true)
119
+ (next remaining))
120
+
121
+ (= arg "--")
122
+ {:opts opts :args (vec (next remaining))}
123
+
124
+ (str/starts-with? arg "--")
125
+ (throw (ex-info (str "Unknown option: " arg) {:arg arg}))
126
+
127
+ :else
128
+ {:opts opts :args (vec remaining)})
129
+ {:opts opts :args []})))
130
+
131
+ ;; =============================================================================
132
+ ;; Commands
133
+ ;; =============================================================================
134
+
135
+ (defn cmd-run
136
+ "Run orchestrator once"
137
+ [opts args]
138
+ (let [swarm-id (make-swarm-id)]
139
+ (if-let [specs (:worker-specs opts)]
140
+ ;; Mixed worker specs: --workers claude:5 codex:4
141
+ (let [workers (mapcat
142
+ (fn [spec]
143
+ (let [{:keys [harness count]} spec]
144
+ (map-indexed
145
+ (fn [idx _]
146
+ (worker/create-worker
147
+ {:id (format "%s-%d" (name harness) idx)
148
+ :swarm-id swarm-id
149
+ :harness harness
150
+ :model (:model opts)
151
+ :iterations 1}))
152
+ (range count))))
153
+ specs)]
154
+ (println (format "Running once with mixed workers (swarm %s):" swarm-id))
155
+ (doseq [spec specs]
156
+ (println (format " %dx %s" (:count spec) (name (:harness spec)))))
157
+ (println)
158
+ (worker/run-workers! workers))
159
+ ;; Simple mode
160
+ (do
161
+ (println (format "Swarm ID: %s" swarm-id))
162
+ (orchestrator/run-once! (assoc opts :swarm-id swarm-id))))))
163
+
164
+ (defn cmd-loop
165
+ "Run orchestrator N times"
166
+ [opts args]
167
+ (let [swarm-id (make-swarm-id)
168
+ iterations (or (some-> (first args) (parse-int nil))
169
+ (:iterations opts)
170
+ 20)]
171
+ (if-let [specs (:worker-specs opts)]
172
+ ;; Mixed worker specs: --workers claude:5 codex:4
173
+ (let [workers (mapcat
174
+ (fn [spec]
175
+ (let [{:keys [harness count]} spec]
176
+ (map-indexed
177
+ (fn [idx _]
178
+ (worker/create-worker
179
+ {:id (format "%s-%d" (name harness) idx)
180
+ :swarm-id swarm-id
181
+ :harness harness
182
+ :model (:model opts)
183
+ :iterations iterations}))
184
+ (range count))))
185
+ specs)]
186
+ (println (format "Starting %d iterations with mixed workers (swarm %s):" iterations swarm-id))
187
+ (doseq [spec specs]
188
+ (println (format " %dx %s" (:count spec) (name (:harness spec)))))
189
+ (println)
190
+ (worker/run-workers! workers))
191
+ ;; Simple mode: --workers N --harness X
192
+ (let [model-str (if (:model opts)
193
+ (format " (model: %s)" (:model opts))
194
+ "")]
195
+ (println (format "Starting %d iterations with %s harness%s..."
196
+ iterations (name (:harness opts)) model-str))
197
+ (println (format "Swarm ID: %s" swarm-id))
198
+ (orchestrator/run-loop! iterations (assoc opts :swarm-id swarm-id))))))
199
+
200
+ (defn cmd-prompt
201
+ "Run ad-hoc prompt as single task"
202
+ [opts args]
203
+ (let [prompt-text (str/join " " args)]
204
+ (when (str/blank? prompt-text)
205
+ (println "Error: prompt text required")
206
+ (System/exit 1))
207
+ ;; Create temporary task
208
+ (let [task {:id (format "prompt-%d" (System/currentTimeMillis))
209
+ :summary prompt-text
210
+ :targets ["src" "tests" "docs"]
211
+ :priority 1}]
212
+ ;; Write to temporary tasks file
213
+ (spit "config/tasks.edn" (pr-str [task]))
214
+ ;; Run
215
+ (orchestrator/run-once! opts))))
216
+
217
+ (defn cmd-status
218
+ "Show status of last run"
219
+ [opts args]
220
+ (let [runs-dir (io/file "runs")
221
+ files (when (.exists runs-dir)
222
+ (->> (.listFiles runs-dir)
223
+ (filter #(.isFile %))
224
+ (sort-by #(.lastModified %) >)))]
225
+ (if-let [latest (first files)]
226
+ (do
227
+ (println (format "Latest run: %s" (.getName latest)))
228
+ (println)
229
+ (with-open [r (io/reader latest)]
230
+ (let [entries (mapv #(json/parse-string % true) (line-seq r))
231
+ by-status (group-by :status entries)]
232
+ (doseq [[status tasks] (sort-by first by-status)]
233
+ (println (format "%s: %d" (name status) (count tasks))))
234
+ (println)
235
+ (println (format "Total: %d tasks" (count entries))))))
236
+ (println "No runs found."))))
237
+
238
+ (defn cmd-worktrees
239
+ "List worktree status"
240
+ [opts args]
241
+ (let [state-file (io/file ".workers/state.edn")]
242
+ (if (.exists state-file)
243
+ (let [pool (read-string (slurp state-file))
244
+ pool' (worktree/list-worktrees pool)]
245
+ (println "Worktrees:")
246
+ (doseq [{:keys [id path status current-task]} pool']
247
+ (println (format " %s: %s%s"
248
+ id
249
+ (name status)
250
+ (if current-task
251
+ (str " [" current-task "]")
252
+ "")))))
253
+ (println "No worktrees initialized."))))
254
+
255
+ (defn cmd-cleanup
256
+ "Remove all worktrees"
257
+ [opts args]
258
+ (let [state-file (io/file ".workers/state.edn")]
259
+ (if (.exists state-file)
260
+ (let [pool (read-string (slurp state-file))]
261
+ (println "Removing worktrees...")
262
+ (worktree/cleanup-pool! pool)
263
+ (println "Done."))
264
+ (println "No worktrees to clean up."))))
265
+
266
+ (defn cmd-context
267
+ "Print current context (for debugging prompts)"
268
+ [opts args]
269
+ (let [ctx (orchestrator/build-context [])]
270
+ (println (:context_header ctx))))
271
+
272
+ (defn cmd-check
273
+ "Check if agent backends are available"
274
+ [opts args]
275
+ (println "Checking agent backends...")
276
+ (doseq [agent-type [:codex :claude]]
277
+ (let [available? (agent/check-available agent-type)]
278
+ (println (format " %s: %s"
279
+ (name agent-type)
280
+ (if available? "✓ available" "✗ not found"))))))
281
+
282
+ (defn- parse-model-string
283
+ "Parse 'harness:model' string into {:harness :model}"
284
+ [s]
285
+ (if (and s (str/includes? s ":"))
286
+ (let [[h m] (str/split s #":" 2)]
287
+ {:harness (keyword h) :model m})
288
+ {:harness :codex :model s}))
289
+
290
+ (defn cmd-swarm
291
+ "Run multiple worker configs from oompa.json in parallel"
292
+ [opts args]
293
+ (let [config-file (or (first args) "oompa.json")
294
+ f (io/file config-file)
295
+ swarm-id (make-swarm-id)]
296
+ (when-not (.exists f)
297
+ (println (format "Config file not found: %s" config-file))
298
+ (println)
299
+ (println "Create oompa.json with format:")
300
+ (println "{")
301
+ (println " \"review_model\": \"codex:codex-5.2\",")
302
+ (println " \"workers\": [")
303
+ (println " {\"model\": \"codex:codex-5.2-mini\", \"iterations\": 10, \"count\": 3},")
304
+ (println " {\"model\": \"codex:codex-5.2\", \"iterations\": 5, \"count\": 1, \"prompt\": \"prompts/planner.md\"}")
305
+ (println " ]")
306
+ (println "}")
307
+ (System/exit 1))
308
+ (let [config (json/parse-string (slurp f) true)
309
+ review-model (some-> (:review_model config) parse-model-string)
310
+ worker-configs (:workers config)
311
+
312
+ ;; Expand worker configs by count
313
+ expanded-workers (mapcat (fn [wc]
314
+ (let [cnt (or (:count wc) 1)]
315
+ (repeat cnt (dissoc wc :count))))
316
+ worker-configs)
317
+
318
+ ;; Convert to worker format
319
+ workers (map-indexed
320
+ (fn [idx wc]
321
+ (let [{:keys [harness model]} (parse-model-string (:model wc))]
322
+ (worker/create-worker
323
+ {:id (str "w" idx)
324
+ :swarm-id swarm-id
325
+ :harness harness
326
+ :model model
327
+ :iterations (or (:iterations wc) 10)
328
+ :custom-prompt (:prompt wc)
329
+ :review-harness (:harness review-model)
330
+ :review-model (:model review-model)})))
331
+ expanded-workers)]
332
+
333
+ (println (format "Swarm config from %s:" config-file))
334
+ (println (format " Swarm ID: %s" swarm-id))
335
+ (when review-model
336
+ (println (format " Review: %s:%s" (name (:harness review-model)) (:model review-model))))
337
+ (println (format " Workers: %d total" (count workers)))
338
+ (doseq [[idx wc] (map-indexed vector worker-configs)]
339
+ (let [{:keys [harness model]} (parse-model-string (:model wc))]
340
+ (println (format " - %dx %s:%s (%d iters%s)"
341
+ (or (:count wc) 1)
342
+ (name harness)
343
+ model
344
+ (or (:iterations wc) 10)
345
+ (if (:prompt wc) (str ", " (:prompt wc)) "")))))
346
+ (println)
347
+
348
+ ;; Run workers using new worker module
349
+ (worker/run-workers! workers))))
350
+
351
+ (defn cmd-tasks
352
+ "Show task status"
353
+ [opts args]
354
+ (tasks/ensure-dirs!)
355
+ (let [status (tasks/status-summary)]
356
+ (println "Task Status:")
357
+ (println (format " Pending: %d" (:pending status)))
358
+ (println (format " Current: %d" (:current status)))
359
+ (println (format " Complete: %d" (:complete status)))
360
+ (println)
361
+ (when (pos? (:pending status))
362
+ (println "Pending tasks:")
363
+ (doseq [t (tasks/list-pending)]
364
+ (println (format " - %s: %s" (:id t) (:summary t)))))
365
+ (when (pos? (:current status))
366
+ (println "In-progress tasks:")
367
+ (doseq [t (tasks/list-current)]
368
+ (println (format " - %s: %s" (:id t) (:summary t)))))))
369
+
370
+ (defn cmd-help
371
+ "Print usage information"
372
+ [opts args]
373
+ (println "AgentNet Orchestrator")
374
+ (println)
375
+ (println "Usage: ./swarm.bb <command> [options]")
376
+ (println)
377
+ (println "Commands:")
378
+ (println " run Run all tasks once")
379
+ (println " loop N Run N iterations")
380
+ (println " swarm [file] Run multiple worker configs from oompa.json (parallel)")
381
+ (println " tasks Show task status (pending/current/complete)")
382
+ (println " prompt \"...\" Run ad-hoc prompt")
383
+ (println " status Show last run summary")
384
+ (println " worktrees List worktree status")
385
+ (println " cleanup Remove all worktrees")
386
+ (println " context Print context block")
387
+ (println " check Check agent backends")
388
+ (println " help Show this help")
389
+ (println)
390
+ (println "Options:")
391
+ (println " --workers N Number of parallel workers (default: 2)")
392
+ (println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 codex:4)")
393
+ (println " --iterations N Number of iterations per worker (default: 1)")
394
+ (println " --harness {codex,claude} Agent harness to use (default: codex)")
395
+ (println " --model MODEL Model to use (e.g., codex-5.2, opus-4.5)")
396
+ (println " --dry-run Skip actual merges")
397
+ (println " --keep-worktrees Don't cleanup worktrees after run")
398
+ (println)
399
+ (println "Examples:")
400
+ (println " ./swarm.bb loop 10 --harness codex --model codex-5.2-mini --workers 3")
401
+ (println " ./swarm.bb loop --workers claude:5 codex:4 --iterations 20")
402
+ (println " ./swarm.bb swarm oompa.json # Run multi-model config"))
403
+
404
+ ;; =============================================================================
405
+ ;; Main Entry Point
406
+ ;; =============================================================================
407
+
408
+ (def commands
409
+ {"run" cmd-run
410
+ "loop" cmd-loop
411
+ "swarm" cmd-swarm
412
+ "tasks" cmd-tasks
413
+ "prompt" cmd-prompt
414
+ "status" cmd-status
415
+ "worktrees" cmd-worktrees
416
+ "cleanup" cmd-cleanup
417
+ "context" cmd-context
418
+ "check" cmd-check
419
+ "help" cmd-help})
420
+
421
+ (defn -main [& args]
422
+ (let [[cmd & rest-args] args]
423
+ (if-let [handler (get commands cmd)]
424
+ (let [{:keys [opts args]} (parse-args rest-args)]
425
+ (try
426
+ (handler opts args)
427
+ (catch Exception e
428
+ (binding [*out* *err*]
429
+ (println (format "Error: %s" (.getMessage e))))
430
+ (System/exit 1))))
431
+ (do
432
+ (cmd-help {} [])
433
+ (when cmd
434
+ (println)
435
+ (println (format "Unknown command: %s" cmd)))
436
+ (System/exit (if cmd 1 0))))))
@@ -0,0 +1,211 @@
1
+ (ns agentnet.core
2
+ "Context assembly utilities shared by the AgentNet orchestrator."
3
+ (:require [agentnet.notes :as notes]
4
+ [babashka.process :as process]
5
+ [clojure.java.io :as io]
6
+ [clojure.string :as str]))
7
+
8
+ (defn now-ms []
9
+ (System/currentTimeMillis))
10
+
11
+ (defn format-ago
12
+ "Return human-readable relative time string for epoch milliseconds."
13
+ [^long ts-ms]
14
+ (let [delta (max 0 (- (now-ms) ts-ms))
15
+ s (long (/ delta 1000))]
16
+ (cond
17
+ (< s 15) "just now"
18
+ (< s 60) (str s "s ago")
19
+ :else (let [m (long (/ s 60))]
20
+ (cond
21
+ (< m 60) (str m "m ago")
22
+ :else (let [h (long (/ m 60))]
23
+ (if (< h 24)
24
+ (str h "h ago")
25
+ (str (long (/ h 24)) "d ago"))))))))
26
+
27
+ (defn- git-cmd
28
+ "Run git command in repo and return trimmed stdout or nil on failure."
29
+ [repo & args]
30
+ (try
31
+ (let [cmd (into ["git" "-C" repo] args)
32
+ {:keys [exit out]} (process/sh cmd {:out :string :err :string})]
33
+ (when (zero? exit)
34
+ (str/trim out)))
35
+ (catch Exception _ nil)))
36
+
37
+ (defn- repo-branch [repo]
38
+ (or (git-cmd repo "rev-parse" "--abbrev-ref" "HEAD") "HEAD"))
39
+
40
+ (defn- repo-head [repo]
41
+ (or (git-cmd repo "rev-parse" "--short" "HEAD") "HEAD"))
42
+
43
+ (defn- bulletize
44
+ "Render a collection as Markdown bullets; fallback provided when empty."
45
+ ([items] (bulletize items "- (empty)"))
46
+ ([items empty-text]
47
+ (if (seq items)
48
+ (str/join "\n" (map #(str "- " %) items))
49
+ empty-text)))
50
+
51
+ (defn- queue-lines [tasks]
52
+ (->> tasks
53
+ (sort-by (juxt (comp (fnil identity 1000) :priority) :id))
54
+ (map (fn [{:keys [id summary]}]
55
+ (format "`%s` • %s" id summary)))))
56
+
57
+ (defn- note->path [{:keys [dir name]}]
58
+ (str dir "/" name))
59
+
60
+ (defn- pending-items []
61
+ (->> (notes/green-ready)
62
+ (map (fn [note]
63
+ (let [paths (or (:targets note)
64
+ [(note->path note)])]
65
+ {:id (or (:id note) (:name note))
66
+ :age (format-ago (:mtime note))
67
+ :files (vec (take 3 paths))})))
68
+ (take 7)
69
+ vec))
70
+
71
+ (defn- recent-notes [recent-sec]
72
+ (let [cut (- (now-ms) (* 1000 (long (or recent-sec 120))))]
73
+ (->> ["scratch" "ready_for_review" "notes_FROM_CTO"]
74
+ (mapcat notes/list-notes)
75
+ (filter #(>= (:mtime %) cut))
76
+ (sort-by :mtime >)
77
+ (take 7)
78
+ (map (fn [note]
79
+ {:path (note->path note)
80
+ :age (format-ago (:mtime note))}))
81
+ vec)))
82
+
83
+ (defn- backlog-entries [tasks]
84
+ (->> tasks
85
+ (sort-by (juxt (comp (fnil identity 1000) :priority) :id))
86
+ (map #(select-keys % [:id :summary]))
87
+ (remove #(nil? (:id %)))
88
+ (take 7)
89
+ vec))
90
+
91
+ (def default-policy-rules
92
+ ["patch-only" ".agent/* only" "minimal diff" "respect targets"])
93
+
94
+ (defn- policy-rules [policy]
95
+ (let [allow (:allow policy)
96
+ deny (:deny policy)
97
+ limit+ (:max-lines-added policy)
98
+ limit- (:max-lines-deleted policy)
99
+ files (:max-files policy)
100
+ custom (remove nil?
101
+ [(when (seq allow)
102
+ (str "allow:" (str/join "," allow)))
103
+ (when (seq deny)
104
+ (str "deny:" (str/join "," deny)))
105
+ (when limit+
106
+ (format "+≤%s lines" limit+))
107
+ (when limit-
108
+ (format "-≤%s lines" limit-))
109
+ (when files
110
+ (format "files≤%s" files))])]
111
+ (->> (concat default-policy-rules custom)
112
+ (distinct)
113
+ (take 7)
114
+ vec)))
115
+
116
+ (defn- next-work-suggestions [tasks pending hotspots]
117
+ (let [top-task (first tasks)
118
+ top-pending (first pending)
119
+ top-hotspot (first hotspots)
120
+ suggestions (cond-> []
121
+ top-pending
122
+ (conj (format "CTO: review %s (%s)" (:id top-pending) (:age top-pending)))
123
+
124
+ top-task
125
+ (conj (format "Engineer: pick %s — %s" (:id top-task) (:summary top-task)))
126
+
127
+ top-hotspot
128
+ (conj (format "CTO: investigate %s" (:path top-hotspot))))]
129
+ (take 5 (if (empty? suggestions)
130
+ ["noop"]
131
+ suggestions))))
132
+
133
+ (defn- render-pending [items]
134
+ (if (seq items)
135
+ (mapcat (fn [{:keys [id age files]}]
136
+ [(format " - id: %s" (pr-str id))
137
+ (format " age: %s" age)
138
+ (format " files: [%s]" (str/join "," (map pr-str files)))])
139
+ items)
140
+ [" - []"]))
141
+
142
+ (defn- render-backlog [items]
143
+ (if (seq items)
144
+ (map (fn [{:keys [id summary]}]
145
+ (format " - {id: %s, summary: %s}" (pr-str id) (pr-str summary)))
146
+ items)
147
+ [" - []"]))
148
+
149
+ (defn- render-hotspots [items]
150
+ (if (seq items)
151
+ (map (fn [{:keys [path age]}]
152
+ (format " - {path: %s, age: %s}" (pr-str path) (pr-str age)))
153
+ items)
154
+ [" - []"]))
155
+
156
+ (defn- render-next-work [items]
157
+ (if (seq items)
158
+ (map #(str " - " (pr-str %)) items)
159
+ [" - \"noop\""]))
160
+
161
+ (defn- render-targets [targets]
162
+ (format "targets: [%s]" (str/join "," (map pr-str (or targets [])))))
163
+
164
+ (defn- render-policy [policy]
165
+ (format "policy: [%s]" (str/join "," (map pr-str policy))))
166
+
167
+ (defn- sanitize-mode [mode]
168
+ (if (#{"review" "propose"} mode) mode "propose"))
169
+
170
+ (defn build-context
171
+ "Return map of context tokens, including YAML header for prompts."
172
+ [{:keys [tasks policy repo recent-sec targets mode-hint]}
173
+ & {:as opts}]
174
+ (let [repo (or repo ".")
175
+ recent-sec (long (or recent-sec 180))
176
+ backlog (backlog-entries tasks)
177
+ pending (pending-items)
178
+ hotspots (recent-notes recent-sec)
179
+ policy-lines (policy-rules (or policy {}))
180
+ next-work (next-work-suggestions backlog pending hotspots)
181
+ branch (repo-branch repo)
182
+ head (repo-head repo)
183
+ header-lines (concat
184
+ ["--- # agent_context"
185
+ (format "repo:\n branch: %s\n head: %s\n recent_window: %ss"
186
+ branch head recent-sec)
187
+ "pending:"]
188
+ (render-pending pending)
189
+ ["backlog:"]
190
+ (render-backlog backlog)
191
+ ["hotspots:"]
192
+ (render-hotspots hotspots)
193
+ [(render-targets (or targets []))
194
+ (render-policy policy-lines)
195
+ "next_work:"]
196
+ (render-next-work next-work)
197
+ [(format "mode: %s" (sanitize-mode mode-hint))
198
+ "---"])
199
+ header (str (str/join "\n" header-lines) "\n")
200
+ queue-md (bulletize (take 7 (queue-lines tasks)))
201
+ hotspots-md (bulletize (map #(format "`%s` (%s)" (:path %) (:age %))
202
+ hotspots)
203
+ "- (none)")
204
+ next-work-md (bulletize next-work "- noop")
205
+ diffstat-md "- (quiet)"]
206
+ {:context_header header
207
+ :ach_yaml header
208
+ :queue_md queue-md
209
+ :recent_files_md hotspots-md
210
+ :next_work_md next-work-md
211
+ :diffstat_md diffstat-md}))