harnex 0.12.0 → 0.29.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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/GUIDE.md +222 -68
  4. data/README.md +78 -48
  5. data/TECHNICAL.md +235 -98
  6. data/agents/gpt.md +60 -0
  7. data/docs/agents.md +119 -0
  8. data/docs/configuration.md +4 -0
  9. data/docs/dispatch-telemetry.md +184 -50
  10. data/docs/events.md +20 -7
  11. data/docs/lanes.md +206 -0
  12. data/docs/mcp.md +102 -0
  13. data/docs/pi-rpc.md +81 -7
  14. data/docs/upgrading-to-0.29.md +87 -0
  15. data/guides/01_dispatch.md +103 -35
  16. data/guides/04_monitoring.md +223 -25
  17. data/guides/05_naming.md +0 -1
  18. data/guides/06_claude.md +96 -0
  19. data/lib/harnex/adapters/base.rb +4 -8
  20. data/lib/harnex/adapters/pi.rb +92 -12
  21. data/lib/harnex/adapters.rb +1 -16
  22. data/lib/harnex/artifact_report.rb +206 -12
  23. data/lib/harnex/cli.rb +68 -50
  24. data/lib/harnex/commands/ask.rb +285 -0
  25. data/lib/harnex/commands/delegate.rb +208 -0
  26. data/lib/harnex/commands/doctor.rb +34 -36
  27. data/lib/harnex/commands/events.rb +3 -3
  28. data/lib/harnex/commands/history.rb +2 -8
  29. data/lib/harnex/commands/land.rb +150 -0
  30. data/lib/harnex/commands/lanes.rb +84 -0
  31. data/lib/harnex/commands/logs.rb +3 -3
  32. data/lib/harnex/commands/mcp.rb +208 -0
  33. data/lib/harnex/commands/orchestration.rb +88 -9
  34. data/lib/harnex/commands/pane.rb +3 -3
  35. data/lib/harnex/commands/preflight.rb +566 -0
  36. data/lib/harnex/commands/reply.rb +125 -0
  37. data/lib/harnex/commands/result.rb +108 -0
  38. data/lib/harnex/commands/run.rb +249 -64
  39. data/lib/harnex/commands/send.rb +121 -16
  40. data/lib/harnex/commands/status.rb +32 -4
  41. data/lib/harnex/commands/stop.rb +14 -4
  42. data/lib/harnex/commands/telemetry.rb +19 -1
  43. data/lib/harnex/commands/wait.rb +421 -56
  44. data/lib/harnex/commands/watch.rb +134 -21
  45. data/lib/harnex/context_file.rb +85 -0
  46. data/lib/harnex/core.rb +100 -15
  47. data/lib/harnex/dispatch_history.rb +157 -10
  48. data/lib/harnex/lanes.rb +548 -0
  49. data/lib/harnex/mcp.rb +362 -0
  50. data/lib/harnex/orchestration.rb +128 -5
  51. data/lib/harnex/owned_process_group.rb +50 -0
  52. data/lib/harnex/owner_stream.rb +399 -0
  53. data/lib/harnex/pricing.rb +4 -9
  54. data/lib/harnex/runtime/activity_tracker.rb +107 -0
  55. data/lib/harnex/runtime/api_server.rb +11 -1
  56. data/lib/harnex/runtime/completion_notifier.rb +90 -11
  57. data/lib/harnex/runtime/inbox.rb +26 -7
  58. data/lib/harnex/runtime/message.rb +5 -2
  59. data/lib/harnex/runtime/runtime_budget.rb +96 -0
  60. data/lib/harnex/runtime/session.rb +684 -294
  61. data/lib/harnex/runtime/stop_request.rb +39 -0
  62. data/lib/harnex/stall.rb +46 -0
  63. data/lib/harnex/telemetry_reconciler.rb +42 -20
  64. data/lib/harnex/terminal_status.rb +5 -5
  65. data/lib/harnex/version.rb +2 -2
  66. data/lib/harnex.rb +16 -0
  67. data/recipes/01_fire_and_watch.md +14 -14
  68. data/recipes/02_chain_implement.md +35 -31
  69. data/recipes/03_buddy.md +1 -1
  70. metadata +34 -12
  71. data/docs/codex-appserver.md +0 -175
  72. data/lib/harnex/adapters/codex.rb +0 -160
  73. data/lib/harnex/adapters/codex_appserver.rb +0 -407
  74. data/lib/harnex/codex/app_server/client.rb +0 -347
data/lib/harnex/cli.rb CHANGED
@@ -37,6 +37,24 @@ module Harnex
37
37
  AgentsGuide.new(@argv.drop(1)).run
38
38
  when "doctor"
39
39
  Doctor.new(@argv.drop(1)).run
40
+ when "preflight"
41
+ Preflight.new(@argv.drop(1)).run
42
+ when "ask"
43
+ Ask.new(@argv.drop(1)).run
44
+ when "delegate"
45
+ Delegate.new(@argv.drop(1)).run
46
+ when "result"
47
+ LaneResult.new(@argv.drop(1)).run
48
+ when "reply"
49
+ Reply.new(@argv.drop(1)).run
50
+ when "land"
51
+ Land.new(@argv.drop(1)).run
52
+ when "discard"
53
+ Land.new(@argv.drop(1), discard: true).run
54
+ when "lanes"
55
+ LanesCommand.new(@argv.drop(1)).run
56
+ when "mcp"
57
+ MCPCommand.new(@argv.drop(1)).run
40
58
  when "orchestration"
41
59
  OrchestrationCommand.new(@argv.drop(1)).run
42
60
  when "artifact-report"
@@ -89,6 +107,24 @@ module Harnex
89
107
  AgentsGuide.usage
90
108
  when "doctor"
91
109
  Doctor.usage
110
+ when "preflight"
111
+ Preflight.usage
112
+ when "ask"
113
+ Ask.usage
114
+ when "delegate"
115
+ Delegate.usage
116
+ when "result"
117
+ LaneResult.usage
118
+ when "reply"
119
+ Reply.usage
120
+ when "land"
121
+ Land.usage
122
+ when "discard"
123
+ Land.usage(discard: true)
124
+ when "lanes"
125
+ LanesCommand.usage
126
+ when "mcp"
127
+ MCPCommand.usage
92
128
  when "orchestration"
93
129
  OrchestrationCommand.usage
94
130
  when "artifact-report"
@@ -102,7 +138,23 @@ module Harnex
102
138
 
103
139
  def usage
104
140
  <<~TEXT
105
- Usage:
141
+ harnex: Pi workers (GPT models) as tools for you and your agents.
142
+
143
+ Hand work to a worker (each runs in a visible tmux window):
144
+ harnex ask QUESTION A read-only answer from another model
145
+ harnex delegate [--worktree NAME] TASK
146
+ A worker does the task; prints its reply
147
+ harnex delegate --no-wait ... Start lanes in parallel, then:
148
+ harnex result [NAME...] Wait for lanes and print their results
149
+ harnex reply NAME MESSAGE Follow up; the worker keeps its context
150
+ harnex land [NAME...] Merge worktree lanes, remove the worktrees
151
+ harnex discard NAME Drop a lane and its worktree
152
+ harnex lanes Your lanes and what each needs next
153
+
154
+ Blocking commands wait 9m by default and exit 124 if the worker is still
155
+ going; run `harnex result NAME` to keep waiting. Exit 0 done, 1 failed.
156
+
157
+ Low-level session control:
106
158
  harnex run <cli> [options] [--] [cli-args...]
107
159
  harnex send --id ID [options] [text...]
108
160
  harnex wait --id ID [options]
@@ -113,61 +165,27 @@ module Harnex
113
165
  harnex events --id ID [options]
114
166
  harnex history [options]
115
167
  harnex pane --id ID [options]
116
- harnex agents-guide [topic]
117
- harnex doctor
168
+
169
+ The same verbs as native MCP tools (Claude Code, Pi):
170
+ harnex mcp install Register the harnex MCP server
171
+ harnex mcp Serve MCP over stdio (clients run this)
172
+
173
+ Setup, docs and telemetry:
174
+ harnex doctor Check Pi and harnex setup
175
+ harnex preflight ADAPTER [options] Prove an adapter with one tiny dispatch
176
+ harnex agents-guide [topic] Agent guides (dispatch, chain, buddy, monitoring, naming, claude)
177
+ harnex guide | recipes Getting started; workflow recipes
118
178
  harnex orchestration sample|report [options]
119
179
  harnex artifact-report init|validate PATH [options]
120
180
  harnex telemetry assert-canonical|reconcile [options]
121
181
  harnex help [command]
122
182
 
123
- Commands:
124
- run Start a wrapped interactive session and local API
125
- send Send text to an active session
126
- wait Block until a session exits or reaches a state
127
- watch Safely watch existing work until done/task_failed/timeout
128
- stop Send the adapter stop sequence to a session
129
- status List live sessions
130
- logs Read session output transcripts
131
- events Stream per-session JSONL runtime events
132
- history List completed dispatches from .harnex/dispatch.jsonl
133
- pane Capture the current tmux pane for a live session
134
- recipes List and read workflow recipes
135
- guide Show the getting started guide
136
- agents-guide
137
- Show agent dispatch, chain, buddy, monitoring, and naming guides
138
- doctor Run preflight checks and optional read-only session sweep
139
- orchestration
140
- Emit external primary samples and report orchestration tax
141
- artifact-report
142
- Validate harness-authored receipts or initialize legacy v1 documents
143
- telemetry
144
- Assert or reconcile canonical dispatch telemetry
145
- help Show command help
146
-
147
- New to harnex? Start with: harnex guide
148
- Working with agents (dispatching tasks)? Read: harnex agents-guide
149
-
150
- Notes:
151
- CLIs with smart prompt detection: #{Adapters.known.join(', ')}
152
- Any other CLI name is launched with generic wrapping.
153
-
154
183
  Examples:
155
- harnex run pi
156
- harnex run aider --id blue-cat
157
- harnex run codex -- --cd /path/to/repo
158
- harnex status
159
- harnex watch --id main --until done --max-wait 15m
160
- harnex logs --id main --follow
161
- harnex events --id main --snapshot
162
- harnex history --limit 20
163
- harnex pane --id main --lines 40
164
- harnex agents-guide dispatch
165
- harnex doctor
166
- harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
167
- harnex artifact-report validate /path/from-dispatch-row.json --final
168
- harnex artifact-report init .harnex/reports/legacy-v1.json
169
- harnex telemetry assert-canonical --canonical .harnex/dispatch.jsonl --json
170
- harnex send --id main --message "Summarize current progress."
184
+ harnex ask "Where is the dispatch row written, and is that path tested?"
185
+ harnex delegate --worktree word-count "Add TextKit.word_count with a test"
186
+ harnex land word-count
187
+ harnex delegate --no-wait --worktree fix-a "Fix A"; harnex delegate --no-wait --worktree fix-b "Fix B"; harnex result
188
+ harnex run pi --tmux pi-i-42 --id pi-i-42 --context-file /tmp/task-42.md
171
189
  TEXT
172
190
  end
173
191
  end
@@ -0,0 +1,285 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "open3"
4
+ require "securerandom"
5
+ require "shellwords"
6
+
7
+ module Harnex
8
+ # `harnex ask` (#85): hand one question to a Pi worker (a GPT or other
9
+ # provider model) and print its answer, so a Claude session consults
10
+ # another model the way it calls any tool. Read-only by default; the
11
+ # dispatch is a normal harnex run (tmux window, receipt, dispatch rows).
12
+ class Ask
13
+ READ_ONLY_TOOLS = "read,grep,find,ls".freeze
14
+ INTENT = "ask".freeze
15
+
16
+ def self.usage
17
+ <<~TEXT
18
+ Usage: harnex ask [options] [--] QUESTION...
19
+ harnex ask --file PATH [--section TEXT] [options]
20
+ harnex ask --resume ID [--timeout DUR] [--json]
21
+
22
+ Ask a Pi worker one question and print its answer on stdout. The worker
23
+ runs in a visible tmux window, reads the repository (it cannot run
24
+ commands unless --write; to run things use `harnex delegate`), and stops
25
+ when it has answered. stderr gets one line naming the lane, its time and model.
26
+ A read-only ask records its dispatch in the global harnex stream, so the
27
+ repository stays untouched; --write records in the repository.
28
+
29
+ Options:
30
+ --model NAME Pi model (provider/model); default: Pi's own default
31
+ (`defaultProvider`/`defaultModel` in ~/.pi/agent/settings.json)
32
+ --fallback-model NAME
33
+ Ask once more with NAME when the first model fails on
34
+ provider capacity or rate limits (provider_capacity)
35
+ --effort LEVEL Reasoning effort / Pi thinking level
36
+ --phase NAME Tag the run with a repo work phase (with --write, checked
37
+ against the repo's allowlist); default: ask
38
+ --cwd DIR Directory the worker works in (default: current directory)
39
+ --file PATH Read the question from PATH (see run --context-file)
40
+ --section TEXT With --file: send only that section
41
+ --write Let the worker edit files and run commands (default:
42
+ read-only tools #{READ_ONLY_TOOLS})
43
+ --id ID Session id (default: pi-ask-<random>)
44
+ --timeout DUR Cap the wait: 540, 9m or 8m30s (default 9m; `none` waits
45
+ without a cap). On 124 the worker keeps going and stderr
46
+ names the command that keeps waiting (`harnex result ID`)
47
+ --resume ID Same as `harnex result ID` (kept for older callers)
48
+ --detach Run headless instead of in a tmux window
49
+ --json Print the full result JSON instead of the answer
50
+ -h, --help Show this help
51
+
52
+ Exit codes: 0 answered, 1 the worker failed (stderr says why), 2 proof
53
+ rejected or a usage error (usage errors print no JSON and no answer),
54
+ 124 timeout (the worker keeps running).
55
+
56
+ Examples:
57
+ harnex ask "Which module parses the dispatch rows, and is it tested?"
58
+ harnex ask --model openai/gpt-5.1 --file /tmp/review.md --timeout 9m
59
+ harnex ask --write --cwd ../lane "Fix the failing test and commit"
60
+ TEXT
61
+ end
62
+
63
+ def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
64
+ @argv = argv.dup
65
+ @out = out
66
+ @err = err
67
+ @harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
68
+ @options = { model: nil, fallback_model: nil, effort: nil, phase: nil, cwd: nil, file: nil, section: nil, write: false,
69
+ id: nil, timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false, resume: nil,
70
+ repo: nil }
71
+ end
72
+
73
+ def run
74
+ question = parse
75
+ if @options[:help]
76
+ @out.puts self.class.usage
77
+ return 0
78
+ end
79
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ return report(*resume_once(@options[:resume]), Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) if @options[:resume]
81
+
82
+ if question.empty? && @options[:file].nil?
83
+ raise OptionParser::MissingArgument, "QUESTION (or --file PATH)"
84
+ end
85
+
86
+ result, exit_code = ask_once(question, @options[:model], @options[:id])
87
+ if exit_code == 1 && @options[:fallback_model] && result.dig("turn_result", "outcome_class") == "provider_capacity"
88
+ @err.puts("harnex ask: #{result['id']} hit provider capacity; asking #{@options[:fallback_model]} instead")
89
+ retry_id = @options[:id] && "#{@options[:id]}-fallback"
90
+ result, exit_code = ask_once(question, @options[:fallback_model], retry_id)
91
+ end
92
+ report(result, exit_code, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
93
+ end
94
+
95
+ private
96
+
97
+ # Each ask is a lane (#87), so `harnex result ID` keeps waiting on it and
98
+ # `harnex reply ID "..."` follows up with the worker's context.
99
+ def ask_once(question, model, id)
100
+ id ||= "pi-ask-#{SecureRandom.hex(2)}"
101
+ lane = register_lane(id, model)
102
+ stdout, status = launch(question, model, id)
103
+ result = parse_result(stdout)
104
+ if !result.empty?
105
+ Lanes.settle(lane, result["turn_result"] || {}, status.exitstatus)
106
+ elsif status.exitstatus != Waiter::DONE_EXIT_TIMEOUT
107
+ # Refused before the worker started (#88); 124 may still come up.
108
+ Lanes.forget(lane)
109
+ end
110
+ [result, status.exitstatus]
111
+ end
112
+
113
+ def register_lane(id, model)
114
+ cwd = File.expand_path(@options[:cwd] || Dir.pwd)
115
+ Lanes.save(
116
+ "name" => id, "kind" => "ask", "repo_root" => Lanes.main_root(cwd) || cwd, "run_root" => run_root,
117
+ "cwd" => cwd, "read_only" => !@options[:write], "owner" => Harnex.ambient_owner_id,
118
+ "requested_model" => model, "effort" => @options[:effort], "phase" => @options[:phase],
119
+ "created_at" => Lanes.now, "launched_at" => Lanes.now, "state" => "running", "turns" => 0
120
+ )
121
+ end
122
+
123
+ def launch(question, model, id)
124
+ argv = @harnex_command + ["run", "pi", "--id", id]
125
+ argv += @options[:detach] ? ["--detach"] : ["--tmux", id]
126
+ argv += ["--cwd", File.expand_path(@options[:cwd] || Dir.pwd)]
127
+ # A consult is not repository work: register and record it under a
128
+ # harnex-owned root (not a git repo, so rows go to the global stream).
129
+ argv += ["--root", ask_root] unless @options[:write]
130
+ if @options[:file]
131
+ argv += ["--context-file", @options[:file]]
132
+ argv += ["--context-section", @options[:section]] if @options[:section]
133
+ else
134
+ argv += ["--context", question]
135
+ end
136
+ argv += ["--auto-stop", "--wait-done", "--intent", INTENT, "--phase", @options[:phase] || INTENT]
137
+ argv += ["--wait-timeout", @options[:timeout].to_s] if @options[:timeout]
138
+ argv += ["--model", model] if model
139
+ argv += ["--effort", @options[:effort]] if @options[:effort]
140
+ argv += ["--", "--no-approve"]
141
+ argv += ["--tools", READ_ONLY_TOOLS] unless @options[:write]
142
+ stdout, status = Open3.capture2(*argv)
143
+ [stdout, status]
144
+ end
145
+
146
+ # A subagent's Bash call is capped at 10 minutes, so a long ask comes back
147
+ # 124 and the caller re-runs this until the answer lands. `wait` prints the
148
+ # turn result unnested; nest it so the answer is reported like a fresh ask.
149
+ def resume_once(id)
150
+ lane = begin
151
+ Lanes.resolve(id)
152
+ rescue OptionParser::InvalidArgument
153
+ nil
154
+ end
155
+ return legacy_resume(id) unless lane
156
+ return [{ "id" => id, "turn_result" => lane["last_turn"] || {} }, lane["state"] == "failed" ? 1 : 0] unless lane["state"] == "running"
157
+
158
+ turn, exit_code = Lanes.wait(lane, @harnex_command, timeout: @options[:timeout])
159
+ Lanes.settle(lane, turn, exit_code || 1)
160
+ [{ "id" => id, "turn_result" => turn }, exit_code || 1]
161
+ end
162
+
163
+ # Asks from before lanes (0.21.0) have no record; their resume line named --repo.
164
+ def legacy_resume(id)
165
+ raise OptionParser::InvalidArgument, "no lane named #{id.inspect}" unless @options[:repo]
166
+
167
+ argv = @harnex_command + ["wait", "--id", id, "--repo", @options[:repo], "--until", "done"]
168
+ argv += ["--timeout", @options[:timeout].to_s] if @options[:timeout]
169
+ stdout, status = Open3.capture2(*argv)
170
+ [{ "id" => id, "turn_result" => parse_result(stdout) }, status.exitstatus]
171
+ end
172
+
173
+ def resume_command(id)
174
+ "#{Harnex.cli_command} result #{id}"
175
+ end
176
+
177
+ # The root `run` records under: ask_root for a consult, the work
178
+ # directory for --write (run takes --cwd as the root when --root is absent).
179
+ def run_root
180
+ return @options[:repo] if @options[:resume]
181
+
182
+ @options[:write] ? File.expand_path(@options[:cwd] || Dir.pwd) : ask_root
183
+ end
184
+
185
+ def parse_result(stdout)
186
+ JSON.parse(stdout.to_s.lines.last.to_s)
187
+ rescue JSON::ParserError
188
+ {}
189
+ end
190
+
191
+ def ask_root
192
+ root = File.join(Harnex::STATE_DIR, "ask")
193
+ FileUtils.mkdir_p(root)
194
+ root
195
+ end
196
+
197
+ def report(result, exit_code, elapsed)
198
+ if @options[:json]
199
+ @out.puts JSON.generate(result) unless result.empty?
200
+ return exit_code
201
+ end
202
+
203
+ turn = result["turn_result"] || {}
204
+ answer = full_answer(turn)
205
+ case exit_code
206
+ when 0
207
+ @out.puts answer.to_s
208
+ @err.puts("harnex ask: #{result['id']} answered in #{elapsed.round}s (model #{turn['model'] || 'Pi default'})")
209
+ when Waiter::DONE_EXIT_TIMEOUT
210
+ @err.puts("harnex ask: #{result['id']} is still working; keep waiting with: #{resume_command(result['id'])}")
211
+ else
212
+ @out.puts answer unless answer.to_s.empty?
213
+ reason = turn["last_error"] || turn["outcome_class"] || turn["status"] || result["error"] || "no result"
214
+ @err.puts("harnex ask: #{result['id'] || 'the worker'} did not answer (#{reason})")
215
+ end
216
+ exit_code
217
+ end
218
+
219
+ def full_answer(turn)
220
+ path = turn["final_message_path"].to_s
221
+ return File.read(path).chomp if turn["final_message_truncated"] && !path.empty? && File.file?(path)
222
+
223
+ turn["final_message"]
224
+ rescue StandardError
225
+ turn["final_message"]
226
+ end
227
+
228
+ def parse
229
+ question = []
230
+ index = 0
231
+ while index < @argv.length
232
+ arg = @argv[index]
233
+ case arg
234
+ when "--"
235
+ question.concat(@argv[(index + 1)..] || [])
236
+ break
237
+ when "-h", "--help" then @options[:help] = true
238
+ when "--write" then @options[:write] = true
239
+ when "--detach" then @options[:detach] = true
240
+ when "--json" then @options[:json] = true
241
+ when "--model", "--effort", "--phase", "--cwd", "--file", "--section", "--id", "--fallback-model", "--resume", "--repo"
242
+ @options[arg.delete_prefix("--").tr("-", "_").to_sym] = value(index += 1, arg)
243
+ # Habits from `harnex run` (#87): an ask already runs in a tmux window
244
+ # named after its id and stops after answering.
245
+ when "--tmux"
246
+ following = @argv[index + 1]
247
+ if following&.match?(Lanes::NAME) && !following.start_with?("-") && @argv.length > index + 2
248
+ @options[:id] ||= following
249
+ index += 1
250
+ end
251
+ when "--auto-stop", "--wait-done" then nil
252
+ when "--wait-timeout"
253
+ @options[:timeout] = Lanes.parse_timeout(value(index += 1, arg), option_name: arg)
254
+ when "--context" then question << value(index += 1, arg)
255
+ when "--context-file" then @options[:file] = value(index += 1, arg)
256
+ when "--timeout"
257
+ @options[:timeout] = Lanes.parse_timeout(value(index += 1, arg), option_name: arg)
258
+ when /\A--/
259
+ raise OptionParser::InvalidOption, "harnex ask: unknown flag #{arg}; see harnex ask --help"
260
+ else
261
+ question << arg
262
+ end
263
+ index += 1
264
+ end
265
+ if @options[:section] && @options[:file].nil?
266
+ raise OptionParser::InvalidOption, "harnex ask: --section requires --file"
267
+ end
268
+ if @options[:resume] && (!question.empty? || @options[:file])
269
+ raise OptionParser::InvalidOption, "harnex ask: --resume takes a lane id and no question"
270
+ end
271
+ if @options[:repo] && @options[:resume].nil?
272
+ raise OptionParser::InvalidOption, "harnex ask: --repo requires --resume"
273
+ end
274
+
275
+ question.join(" ")
276
+ end
277
+
278
+ def value(index, name)
279
+ value = @argv[index]
280
+ raise OptionParser::MissingArgument, name if value.nil? || value.start_with?("--")
281
+
282
+ value
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,208 @@
1
+ module Harnex
2
+ # `harnex delegate` (#87): hand one task to a Pi worker and print its
3
+ # reply plus a lane footer (state, time, model, cost, commits, next
4
+ # commands). With --worktree NAME the worker gets its own git worktree on
5
+ # branch harnex/NAME, so parallel lanes never touch the main checkout.
6
+ class Delegate
7
+ def self.usage
8
+ <<~TEXT
9
+ Usage: harnex delegate [options] [--] TASK...
10
+ harnex delegate --file PATH [options]
11
+
12
+ Hand a task to a Pi worker (a GPT model by default) and print its reply
13
+ and a lane summary. The worker runs in a visible tmux window, may edit
14
+ files and run commands, and stops when it has replied. Follow up with
15
+ `harnex reply NAME "..."`; it keeps the worker's context.
16
+
17
+ Options:
18
+ --worktree NAME Work in a new git worktree (.harnex/worktrees/NAME, branch
19
+ harnex/NAME); finish with `harnex land NAME` or
20
+ `harnex discard NAME`
21
+ --name NAME Lane name without a worktree (default: from the task)
22
+ --cwd DIR Directory the worker works in (default: current directory)
23
+ --file PATH Read the task from PATH
24
+ --model NAME Pi model (provider/model); default: Pi's own default
25
+ --effort LEVEL Reasoning effort / Pi thinking level
26
+ --phase NAME Tag the run with a repo work phase (checked against the
27
+ repo's .harnex/config.json allowlist); default: delegate,
28
+ which every allowlist accepts
29
+ --no-wait Start the lane and return at once; collect with
30
+ `harnex result` (all your lanes) or `harnex result NAME`
31
+ --timeout DUR Wait at most DUR (default 9m, fits a 10-minute tool cap;
32
+ `none` waits without a cap). On 124 the worker keeps going:
33
+ run `harnex result NAME`
34
+ --detach Run headless instead of in a tmux window
35
+ --json Print the lane record and result as JSON
36
+ -h, --help Show this help
37
+
38
+ Exit codes: 0 done, 1 failed, 2 usage error, 124 still working.
39
+
40
+ Examples:
41
+ harnex delegate --worktree word-count "Add TextKit.word_count with a test"
42
+ harnex delegate --no-wait --worktree fix-a "Fix A" # then: harnex result
43
+ harnex delegate "Run bin/slow-bench and report the score"
44
+ TEXT
45
+ end
46
+
47
+ def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
48
+ @argv = argv.dup
49
+ @out = out
50
+ @err = err
51
+ @harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
52
+ @options = { worktree: nil, name: nil, cwd: nil, file: nil, model: nil, effort: nil, phase: nil, wait: true,
53
+ timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false }
54
+ end
55
+
56
+ def run
57
+ task = parse
58
+ if @options[:help]
59
+ @out.puts self.class.usage
60
+ return 0
61
+ end
62
+ task = File.read(@options[:file]) if task.empty? && @options[:file]
63
+ raise OptionParser::MissingArgument, "TASK (or --file PATH)" if task.strip.empty?
64
+
65
+ lane = @lane = build_lane(task)
66
+ brief = Lanes.write_brief(lane, task)
67
+ turn, exit_code = Lanes.launch(lane, @harnex_command, context_args: ["--context-file", brief],
68
+ wait: @options[:wait], timeout: @options[:timeout],
69
+ detach: @options[:detach])
70
+ return started(lane) unless @options[:wait]
71
+
72
+ lane = Lanes.settle(lane, turn, exit_code)
73
+ report(lane, turn, exit_code)
74
+ rescue Lanes::LaunchError => e
75
+ discard_unstarted
76
+ @err.puts(e.message)
77
+ 1
78
+ end
79
+
80
+ private
81
+
82
+ def build_lane(task)
83
+ workdir = File.expand_path(@options[:cwd] || Dir.pwd)
84
+ raise OptionParser::InvalidArgument, "--cwd #{workdir} is not a directory" unless File.directory?(workdir)
85
+
86
+ root = Lanes.main_root(workdir)
87
+ raise OptionParser::InvalidArgument, "--worktree needs a git repository (#{workdir} is not in one)" if @options[:worktree] && root.nil?
88
+
89
+ Lanes.sweep_merged(root)
90
+ root ||= workdir
91
+ name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task))
92
+ ensure_free!(root, name)
93
+ lane = {
94
+ "name" => name, "kind" => "delegate", "repo_root" => root, "run_root" => root, "cwd" => workdir,
95
+ "owner" => Harnex.ambient_owner_id, "requested_model" => @options[:model], "effort" => @options[:effort],
96
+ "phase" => @options[:phase], "created_at" => Lanes.now, "turns" => 0
97
+ }
98
+ if @options[:worktree]
99
+ warn_uncommitted(root)
100
+ lane["worktree"], lane["branch"], lane["base_sha"] = Lanes.create_worktree(root, name)
101
+ lane["cwd"] = lane["worktree"]
102
+ else
103
+ Harnex.ensure_git_excluded(root) if Lanes.main_root(workdir)
104
+ lane["base_sha"] = Lanes.head_sha(workdir)
105
+ end
106
+ lane
107
+ end
108
+
109
+ # A lane that never started leaves nothing behind.
110
+ def discard_unstarted
111
+ return unless @lane && @lane["turns"].to_i.zero?
112
+
113
+ Lanes.remove_worktree(@lane, force: true) if @lane["worktree"]
114
+ Lanes.forget(@lane)
115
+ end
116
+
117
+ def ensure_free!(root, name)
118
+ existing = Lanes.load(root, name)
119
+ if Lanes.never_started?(existing)
120
+ Lanes.forget(existing)
121
+ existing = nil
122
+ end
123
+ if Lanes.open?(existing)
124
+ raise OptionParser::InvalidArgument,
125
+ "lane #{name} already exists (#{existing['state']}): follow up with `harnex reply #{name} \"...\"`, " \
126
+ "or `harnex land #{name}` / `harnex discard #{name}` first"
127
+ end
128
+ return unless Harnex.read_registry(root, name)
129
+
130
+ raise OptionParser::InvalidArgument, "a harnex session named #{name} is running; pick another name"
131
+ end
132
+
133
+ def warn_uncommitted(root)
134
+ dirty = Lanes.dirty_paths(root)
135
+ return if dirty.empty?
136
+
137
+ @err.puts("harnex delegate: the main checkout has #{dirty.length} uncommitted change(s); " \
138
+ "the worktree starts from HEAD and won't see them")
139
+ end
140
+
141
+ def started(lane)
142
+ if @options[:json]
143
+ @out.puts JSON.generate(lane)
144
+ else
145
+ where = lane["worktree"] ? " in #{Lanes.relative(lane['repo_root'], lane['worktree'])}" : ""
146
+ @out.puts "── #{lane['name']}: started#{where}"
147
+ @out.puts " collect: harnex result #{lane['name']} (or `harnex result` for all your lanes)"
148
+ end
149
+ 0
150
+ end
151
+
152
+ def report(lane, turn, exit_code)
153
+ if @options[:json]
154
+ @out.puts JSON.generate(lane: lane, result: turn, exit_code: exit_code)
155
+ else
156
+ @out.puts Lanes.render(lane, turn, exit_code)
157
+ end
158
+ exit_code
159
+ end
160
+
161
+ def parse
162
+ task = []
163
+ index = 0
164
+ while index < @argv.length
165
+ arg = @argv[index]
166
+ case arg
167
+ when "--"
168
+ task.concat(@argv[(index + 1)..] || [])
169
+ break
170
+ when "-h", "--help" then @options[:help] = true
171
+ when "--no-wait" then @options[:wait] = false
172
+ when "--detach" then @options[:detach] = true
173
+ when "--json" then @options[:json] = true
174
+ when "--worktree", "--name", "--cwd", "--file", "--model", "--effort", "--phase"
175
+ @options[arg.delete_prefix("--").to_sym] = value(index += 1, arg)
176
+ when "--timeout", "--wait-timeout"
177
+ @options[:timeout] = Lanes.parse_timeout(value(index += 1, arg))
178
+ # Habits from `harnex run`: a lane already runs in a tmux window named
179
+ # after it, stops after its reply and waits for it, so these are
180
+ # accepted rather than refused.
181
+ when "--id" then @options[:name] = value(index += 1, arg)
182
+ when "--tmux" then index += 1 if lane_name_follows?(index) && (@options[:name] ||= @argv[index + 1])
183
+ when "--auto-stop", "--wait-done" then nil
184
+ when "--context" then task << value(index += 1, arg)
185
+ when "--context-file" then @options[:file] = value(index += 1, arg)
186
+ when /\A--/
187
+ raise OptionParser::InvalidOption, "harnex delegate: unknown flag #{arg}; see harnex delegate --help"
188
+ else
189
+ task << arg
190
+ end
191
+ index += 1
192
+ end
193
+ task.join(" ")
194
+ end
195
+
196
+ def lane_name_follows?(index)
197
+ following = @argv[index + 1]
198
+ !following.nil? && following.match?(Lanes::NAME) && !following.start_with?("-")
199
+ end
200
+
201
+ def value(index, name)
202
+ value = @argv[index]
203
+ raise OptionParser::MissingArgument, name if value.nil? || value.start_with?("--")
204
+
205
+ value
206
+ end
207
+ end
208
+ end