maf 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +18 -1
- data/assets/agents-contract.md +2 -0
- data/assets/coord +145 -21
- data/assets/dispatcher +59 -13
- data/install.md +6 -1
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/cli.rb +19 -9
- data/lib/maf/flow/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +2 -1
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/manifest.rb +1 -1
- data/lib/maf/flow/models.rb +11 -0
- data/lib/maf/flow/options.rb +29 -3
- data/lib/maf/flow/prompt_text.rb +6 -3
- data/lib/maf/flow/report.rb +6 -5
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +5 -5
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow.rb +3 -1
- data/lib/maf/menu.rb +48 -14
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +2 -2
- data/lib/maf/setup_agent/launcher.rb +6 -4
- data/lib/maf/setup_agent/manifest.rb +9 -3
- data/lib/maf/setup_agent.rb +11 -11
- data/lib/maf/shared/harness/defaults.rb +10 -3
- data/lib/maf/shared/harnesses/claude.rb +6 -3
- data/lib/maf/shared/harnesses/codex.rb +39 -10
- data/lib/maf/shared/harnesses/cursor.rb +60 -4
- data/lib/maf/shared/harnesses/hermes.rb +21 -11
- data/lib/maf/shared/harnesses/opencode.rb +25 -2
- data/lib/maf/shared/roles.rb +4 -0
- data/lib/maf/team.rb +5 -4
- data/lib/maf/team_command.rb +3 -1
- data/lib/maf/untrack.rb +7 -0
- data/lib/maf/version.rb +1 -1
- data/lib/maf/worker_control.rb +12 -1
- data/lib/maf/worker_list.rb +83 -0
- data/templates/roles.yml +14 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d92abf8bbcca7ba5044ac6185534e883a55a1387e05460ac4142e1d89f3d13e
|
|
4
|
+
data.tar.gz: 25eaa46e4ed438fef70a08841137fc87c7ede35dfe9c09ff4d053a351e11e9ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96324cff6b379cb0ec71cbe0d8a15d127a950ddfb7e3997012f4f9b6c4759508a34668fc06a7702d04030ebf65875335c05a6a5836f7959422cbaf107d6abe92
|
|
7
|
+
data.tar.gz: 9faa6836a004d143d9bcdfc7852d4479b2f9b43f40d09efcab44c32343607b9bcf73949c7e62235b772f817d37845e0e2a5955deefab1c576477a962798e8aa2
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.0] - 2026-10-21
|
|
6
|
+
|
|
7
|
+
- Fix: each dispatched Hermes run failed. Hermes 0.22 refuses `--run-budget` with `--format
|
|
8
|
+
stream-json`. maf no longer passes `--run-budget`. The `--timeout` of the dispatcher still
|
|
9
|
+
stops a run.
|
|
10
|
+
- Fix: in a Hermes run, `coord` did not get `COORD_WORKER` and the other flow variables, so
|
|
11
|
+
`coord done` refused to close the task. Hermes runs each session in a shared gateway and
|
|
12
|
+
passes only the names in `terminal.client_env_passthrough`. `maf add` and `maf update` print
|
|
13
|
+
the `hermes config set` command that adds the flow names to that list.
|
|
14
|
+
- `maf worker stop|start|restart|status ROLE` finds the worker when the role has one worker,
|
|
15
|
+
for example `tester-bot`.
|
|
16
|
+
- `maf worker list` names the stopped workers also when no worker runs.
|
|
17
|
+
- A worker does not start a team of its own. `.maf/claude/settings.json` sets
|
|
18
|
+
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0`, so a subagent that Claude names in a `maf start`
|
|
19
|
+
session does not start as a teammate. `maf update` adds the setting.
|
|
20
|
+
- Hermes: each role gets a toolset list. A role that edits no longer gets `kanban`,
|
|
21
|
+
`delegation`, `cronjob`, `computer_use`, and the other toolsets outside the list.
|
|
22
|
+
- Fix: a Hermes role with `can_edit: false` did not get the graph MCP server, because its
|
|
23
|
+
toolset list did not name `mcp-graphify-<project>`.
|
|
24
|
+
- The dispatcher blocks a task after 3 runs in a row that leave it claimed and not done, and
|
|
25
|
+
escalates it to the project manager. Before, it resumed such a task each hour without end.
|
|
26
|
+
New commands: `coord block ID [--kind K] TEXT` holds a task out of `coord next`, and
|
|
27
|
+
`coord unblock ID` returns it. `coord status` and `coord board` show blocked tasks.
|
|
28
|
+
- A worker that cannot finish a task blocks it with a kind: `needs_input`, `dependency`, or
|
|
29
|
+
`capability`. coord tells the architect (dependency) or the project manager (the other kinds).
|
|
30
|
+
Before, the worker kept the claim, and the dispatcher resumed the task each hour.
|
|
31
|
+
- `maf worker list` shows each running worker: its role, harness, mode (session or dispatch),
|
|
32
|
+
pid, and start time. A worker that runs twice, for example as a session and as a
|
|
33
|
+
dispatcher, gets a warning. The last line names the registered workers that do not run.
|
|
34
|
+
- Effort levels per role (#44): `maf add --effort ROLE=LEVEL`, `maf start` and `maf prepare
|
|
35
|
+
--effort LEVEL`, and `dispatcher --effort LEVEL`. maf passes the level to the harness in its
|
|
36
|
+
own form: `--effort` (Claude Code), `model_reasoning_effort` (Codex), `--reasoning` (Hermes),
|
|
37
|
+
`--variant` (opencode), and the model name for Cursor (`gpt-5.3-codex` + `high` =
|
|
38
|
+
`gpt-5.3-codex-high`). Each role has a default level in `roles.yml` (`effort:`), used when the
|
|
39
|
+
harness lists it. After `maf update`, the claude, codex, hermes, and cursor agents get the
|
|
40
|
+
default of their role. The interactive mode shows the levels of each model and asks for one.
|
|
41
|
+
- Fix: Codex 0.163 ran the commands of a worker in its shared background server. The commands
|
|
42
|
+
got the environment of the shell that started the server, not of the worker: no `.maf/bin` on
|
|
43
|
+
PATH, no `COORD_ROLE` and `COORD_WORKER`. maf now starts Codex with `--no-daemon`.
|
|
44
|
+
- Fix: after a message run, the dispatcher counted the unclaimed tasks as dispatched and backed
|
|
45
|
+
off: 2 minutes, then 4, then more. A message run must not claim a task, so the task waited
|
|
46
|
+
without a reason. The task now runs in the next cycle.
|
|
47
|
+
|
|
5
48
|
## [0.2.0] - 2026-10-14
|
|
6
49
|
|
|
7
50
|
- The sandbox also protects the flow config in `.maf/claude/` and `.maf/mcp/`, and each script
|
data/README.md
CHANGED
|
@@ -164,7 +164,7 @@ multi_agent_flow/
|
|
|
164
164
|
GETTING_STARTED.md # first-time walkthrough (concepts + manual setup)
|
|
165
165
|
USER_MANUAL.md # full team setup reference
|
|
166
166
|
maf.gemspec # the gem: files, version, the webrick dependency
|
|
167
|
-
Gemfile # development gems: rake, minitest, rubocop
|
|
167
|
+
Gemfile # development gems: rake, minitest, rubocop, mutant
|
|
168
168
|
CHANGELOG.md # changes per version
|
|
169
169
|
exe/
|
|
170
170
|
maf # the maf command line tool (gem install maf puts it on PATH)
|
|
@@ -187,6 +187,7 @@ multi_agent_flow/
|
|
|
187
187
|
team_command.rb # maf team: shows the team, or sets its budget
|
|
188
188
|
retire.rb # maf retire: removes one worker and archives its state
|
|
189
189
|
worker_control.rb # maf worker: stops, starts, or restarts one worker
|
|
190
|
+
worker_list.rb # maf worker list: shows the sessions and dispatchers that run
|
|
190
191
|
flow/role_catalog.rb # merges .maf/roles.yml over the built-in roles
|
|
191
192
|
flow/workflow.rb # reads .maf/workflow.md for the architect prompt
|
|
192
193
|
flow/mcp_config.rb # writes the graphify MCP server into .maf/mcp/
|
|
@@ -231,6 +232,7 @@ multi_agent_flow/
|
|
|
231
232
|
hook_session_test.rb # tests for session isolation
|
|
232
233
|
hook_config_test.rb # tests for project hooks
|
|
233
234
|
worker_control_test.rb # tests for maf worker
|
|
235
|
+
worker_list_test.rb # tests for maf worker list
|
|
234
236
|
untrack_test.rb # tests for maf untrack
|
|
235
237
|
analyst_test.rb # tests for the analyst
|
|
236
238
|
shared_test.rb # tests for lib/maf/shared/
|
|
@@ -336,6 +338,20 @@ them when you run a test file directly, so a test never writes to that board:
|
|
|
336
338
|
env -u TASKRC -u COORD_DIR -u COORD_ROLE -u COORD_WORKER ruby test/coord_test.rb
|
|
337
339
|
```
|
|
338
340
|
|
|
341
|
+
`rake mutant` runs the mutation tests with [mutant](https://github.com/mbj/mutant).
|
|
342
|
+
Mutant changes the code in `lib/` and runs the tests again. A change that no test
|
|
343
|
+
finds is an alive mutation: add a test for it. Mutant needs Ruby 3.3 or later.
|
|
344
|
+
It is free for open source projects (`usage: opensource` in `.mutant.yml`).
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
bundle exec rake mutant # the subjects in .mutant.yml
|
|
348
|
+
bundle exec rake mutant SUBJECT='Maf::Budget*' # other subjects
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Mutant runs only the test classes that name their code with `cover`. To add a
|
|
352
|
+
subject, require `test/mutant_cover.rb` in the test file, add `cover "Maf::X*"` to
|
|
353
|
+
the test class, and add `Maf::X*` to `matcher.subjects` in `.mutant.yml`.
|
|
354
|
+
|
|
339
355
|
```sh
|
|
340
356
|
ruby test/coord_test.rb # covers the coord CLI
|
|
341
357
|
ruby test/installer_test.rb # covers bootstrap.rb, flow.rb, setup_agent.rb
|
|
@@ -353,6 +369,7 @@ ruby test/dispatcher_test.rb # covers the dispatcher
|
|
|
353
369
|
ruby test/board_watch_test.rb # covers the board watcher
|
|
354
370
|
ruby test/context_watch_test.rb # covers the context-watch hook
|
|
355
371
|
ruby test/worker_control_test.rb # covers maf worker
|
|
372
|
+
ruby test/worker_list_test.rb # covers maf worker list
|
|
356
373
|
ruby test/untrack_test.rb # covers maf untrack
|
|
357
374
|
ruby test/analyst_test.rb # covers the analyst
|
|
358
375
|
ruby test/shared_test.rb # covers lib/maf/shared/
|
data/assets/agents-contract.md
CHANGED
|
@@ -31,6 +31,8 @@ coord claim ID | start-task ID | done ID # take a task, check out its branch
|
|
|
31
31
|
coord annotate ID TEXT # add a note to a task
|
|
32
32
|
coord lesson ID dead_end|corrected TEXT # an approach that failed, or the right way: the graph keeps it
|
|
33
33
|
coord escalate [--task ID] TEXT # a problem you cannot fix: the project manager asks the user
|
|
34
|
+
coord block ID --kind K TEXT # you cannot finish: K is needs_input, dependency, or capability
|
|
35
|
+
coord unblock ID # lead roles: the blocker is gone; the task returns to coord next
|
|
34
36
|
coord msg --from A [--task ID] [--fyi] TO TEXT # message a role or a worker; --task also notes the task
|
|
35
37
|
# --fyi: no run starts; the next run of TO reads it
|
|
36
38
|
coord broadcast --from A [--to workers|leads|all] TEXT
|
data/assets/coord
CHANGED
|
@@ -54,6 +54,7 @@ module Coord
|
|
|
54
54
|
# A claim older than this is presumed abandoned (crashed worker) and can be
|
|
55
55
|
# taken over by `next`/`claim` without --force. Override with COORD_LEASE_TTL.
|
|
56
56
|
DEFAULT_LEASE_TTL = 4 * 3600
|
|
57
|
+
BLOCKED_TAG = "blocked"
|
|
57
58
|
DEFAULT_POLL_INTERVAL = 60
|
|
58
59
|
|
|
59
60
|
# Lead roles plan and talk to the user. They never own a task, so they wait
|
|
@@ -86,6 +87,10 @@ module Coord
|
|
|
86
87
|
coord conflicts list pending tasks with overlapping scopes
|
|
87
88
|
coord claim ID [--force] claim for COORD_WORKER (atomic; refused for lead roles)
|
|
88
89
|
coord unclaim ID release a claim without finishing it
|
|
90
|
+
coord block ID [--kind K] TEXT... release the claim, hold the task out of coord next, and tell
|
|
91
|
+
the architect (K=dependency) or the project manager
|
|
92
|
+
(K=needs_input, the default, capability, or failing)
|
|
93
|
+
coord unblock ID return a blocked task to the backlog
|
|
89
94
|
coord done ID [--force] complete (refused while the task branch lacks the goal head,
|
|
90
95
|
or while the verify command fails)
|
|
91
96
|
coord annotate ID TEXT...
|
|
@@ -120,7 +125,7 @@ module Coord
|
|
|
120
125
|
COMMANDS = {
|
|
121
126
|
"init" => :cmd_init, "add" => :cmd_add, "next" => :cmd_next,
|
|
122
127
|
"conflicts" => :cmd_conflicts,
|
|
123
|
-
"claim" => :cmd_claim, "unclaim" => :cmd_unclaim,
|
|
128
|
+
"claim" => :cmd_claim, "unclaim" => :cmd_unclaim, "block" => :cmd_block, "unblock" => :cmd_unblock,
|
|
124
129
|
"done" => :cmd_done, "annotate" => :cmd_annotate, "lesson" => :cmd_lesson,
|
|
125
130
|
"msg" => :cmd_msg, "inbox" => :cmd_inbox, "await" => :cmd_await, "broadcast" => :cmd_broadcast,
|
|
126
131
|
"log" => :cmd_log, "hooks" => :cmd_hooks,
|
|
@@ -346,6 +351,9 @@ module Coord
|
|
|
346
351
|
rescue ArgumentError
|
|
347
352
|
false
|
|
348
353
|
end
|
|
354
|
+
|
|
355
|
+
def self.blocked?(task) = Array(task["tags"]).include?(BLOCKED_TAG)
|
|
356
|
+
def self.held?(task) = waiting?(task) || blocked?(task)
|
|
349
357
|
end
|
|
350
358
|
|
|
351
359
|
# TaskCli runs Taskwarrior and parses its output. No domain logic here.
|
|
@@ -408,6 +416,8 @@ module Coord
|
|
|
408
416
|
export([id, "export"]).first || {}
|
|
409
417
|
end
|
|
410
418
|
|
|
419
|
+
def with_goal(uuid) = export(["goalid:#{uuid}", "export"])
|
|
420
|
+
|
|
411
421
|
private
|
|
412
422
|
|
|
413
423
|
def run(*args)
|
|
@@ -517,7 +527,7 @@ module Coord
|
|
|
517
527
|
end
|
|
518
528
|
|
|
519
529
|
def mine(role, worker)
|
|
520
|
-
pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] }
|
|
530
|
+
pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] && actionable?(task) }
|
|
521
531
|
end
|
|
522
532
|
|
|
523
533
|
# Returns the prior holder (nil if there was none) so the caller can warn
|
|
@@ -537,10 +547,18 @@ module Coord
|
|
|
537
547
|
@cli.modify(id, "worker:")
|
|
538
548
|
end
|
|
539
549
|
|
|
550
|
+
def block(id)
|
|
551
|
+
unclaim(id) if @cli.task(id)["start"]
|
|
552
|
+
@cli.modify(id, "+#{BLOCKED_TAG}")
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def unblock(id) = @cli.modify(id, "-#{BLOCKED_TAG}")
|
|
556
|
+
|
|
540
557
|
def find(id) = @cli.task(id)
|
|
541
558
|
# A short id of digits only would read as a task number. The filter avoids that.
|
|
542
559
|
def find_short(short) = @cli.task("uuid.startswith:#{short}")
|
|
543
560
|
def for_goal(uuid) = pending.select { |task| task["goalid"] == uuid }
|
|
561
|
+
def any_for_goal?(uuid) = @cli.with_goal(uuid).any?
|
|
544
562
|
def done(id) = @cli.done(id)
|
|
545
563
|
def annotate(id, text) = @cli.annotate(id, text)
|
|
546
564
|
def pending = @pending ||= @cli.pending
|
|
@@ -548,7 +566,7 @@ module Coord
|
|
|
548
566
|
|
|
549
567
|
private
|
|
550
568
|
|
|
551
|
-
def actionable?(task) = !Schedule.
|
|
569
|
+
def actionable?(task) = !Schedule.held?(task)
|
|
552
570
|
|
|
553
571
|
def held_by?(task, worker) = !worker.to_s.empty? && task["worker"] == worker
|
|
554
572
|
|
|
@@ -1097,9 +1115,10 @@ end
|
|
|
1097
1115
|
# Board renders status summaries and the Obsidian kanban file.
|
|
1098
1116
|
class Board
|
|
1099
1117
|
SECTIONS = {
|
|
1100
|
-
"Backlog" => ->(task) { !Schedule.
|
|
1101
|
-
"In progress" => ->(task) { !Schedule.
|
|
1102
|
-
"Waiting" => ->(task) { Schedule.waiting?(task) }
|
|
1118
|
+
"Backlog" => ->(task) { !Schedule.held?(task) && !task["start"] },
|
|
1119
|
+
"In progress" => ->(task) { !Schedule.held?(task) && task["start"] },
|
|
1120
|
+
"Waiting" => ->(task) { Schedule.waiting?(task) && !Schedule.blocked?(task) },
|
|
1121
|
+
"Blocked" => ->(task) { Schedule.blocked?(task) }
|
|
1103
1122
|
}.freeze
|
|
1104
1123
|
|
|
1105
1124
|
def initialize(tasks, paths)
|
|
@@ -1149,6 +1168,7 @@ end
|
|
|
1149
1168
|
end
|
|
1150
1169
|
|
|
1151
1170
|
def bucket(task)
|
|
1171
|
+
return "blocked" if Schedule.blocked?(task)
|
|
1152
1172
|
return "waiting" if Schedule.waiting?(task)
|
|
1153
1173
|
|
|
1154
1174
|
task["start"] ? "in-progress" : "backlog"
|
|
@@ -1185,6 +1205,8 @@ end
|
|
|
1185
1205
|
def self.run(dir, *args) = system("git", "-C", dir, *args, out: File::NULL, err: File::NULL)
|
|
1186
1206
|
def self.branch?(dir, name) = run(dir, "show-ref", "--verify", "--quiet", "refs/heads/#{name}")
|
|
1187
1207
|
def self.clean?(dir) = `git -C #{Shellwords.escape(dir)} status --porcelain --untracked-files=no`.strip.empty?
|
|
1208
|
+
def self.changed(dir) = out(dir, "diff", "HEAD", "--name-only").lines(chomp: true)
|
|
1209
|
+
def self.remote_branch?(dir, remote, name) = run(dir, "ls-remote", "--exit-code", "--heads", remote, name)
|
|
1188
1210
|
def self.out(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read).strip
|
|
1189
1211
|
def self.ancestor?(dir, older, newer) = run(dir, "merge-base", "--is-ancestor", older, newer)
|
|
1190
1212
|
def self.own_commits?(dir, base, branch) = out(dir, "rev-list", "--count", "#{base}..#{branch}").to_i.positive?
|
|
@@ -1245,11 +1267,12 @@ end
|
|
|
1245
1267
|
goals.each { |goal| puts goal_line(goal) }
|
|
1246
1268
|
end
|
|
1247
1269
|
|
|
1248
|
-
def show(id)
|
|
1270
|
+
def show(id, artifacts_dir)
|
|
1249
1271
|
goal = find!(id)
|
|
1250
1272
|
puts goal_line(goal)
|
|
1251
1273
|
list = @tasks.for_goal(goal["uuid"])
|
|
1252
1274
|
list.empty? ? puts(" no open tasks") : list.each { |task| puts task_line(task) }
|
|
1275
|
+
plan_hint(goal["uuid"], artifacts_dir)
|
|
1253
1276
|
end
|
|
1254
1277
|
|
|
1255
1278
|
def done(id)
|
|
@@ -1266,6 +1289,13 @@ end
|
|
|
1266
1289
|
|
|
1267
1290
|
private
|
|
1268
1291
|
|
|
1292
|
+
def plan_hint(uuid, artifacts_dir)
|
|
1293
|
+
plan = File.join(artifacts_dir, uuid, "plan.md")
|
|
1294
|
+
return unless File.exist?(plan) && !@tasks.any_for_goal?(uuid)
|
|
1295
|
+
|
|
1296
|
+
puts " plan ready, no tasks: #{plan}. The plan waits for approval."
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1269
1299
|
def goal_line(goal)
|
|
1270
1300
|
open = @tasks.for_goal(goal["uuid"]).size
|
|
1271
1301
|
[goal["uuid"], goal["description"], self.class.branch(goal["uuid"]), "open=#{open}"].join("\t")
|
|
@@ -1286,7 +1316,7 @@ end
|
|
|
1286
1316
|
end
|
|
1287
1317
|
|
|
1288
1318
|
def start(uuid, base)
|
|
1289
|
-
|
|
1319
|
+
refuse_dirty!
|
|
1290
1320
|
|
|
1291
1321
|
branch = "task/#{Goals.short(uuid)}"
|
|
1292
1322
|
raise Error, "git checkout #{branch} failed" unless checkout(branch, base)
|
|
@@ -1296,6 +1326,17 @@ end
|
|
|
1296
1326
|
|
|
1297
1327
|
private
|
|
1298
1328
|
|
|
1329
|
+
FLOW_FILES = "These are files of the flow, and git tracks them. In the main checkout, run maf untrack and commit."
|
|
1330
|
+
|
|
1331
|
+
def refuse_dirty!
|
|
1332
|
+
paths = Git.changed(@dir)
|
|
1333
|
+
return if paths.empty?
|
|
1334
|
+
|
|
1335
|
+
flow = paths.all? { |path| path.start_with?(".maf/") || Worktree::RUNTIME_FILES.include?(path) }
|
|
1336
|
+
raise Error, ["uncommitted changes in #{@dir}: #{paths.first(5).join(", ")}#{" ..." if paths.size > 5}.",
|
|
1337
|
+
flow ? FLOW_FILES : "Commit or stash them first."].join(" ")
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1299
1340
|
def checkout(branch, base)
|
|
1300
1341
|
return Git.run(@dir, "checkout", "-q", branch) if Git.branch?(@dir, branch)
|
|
1301
1342
|
|
|
@@ -1351,8 +1392,8 @@ end
|
|
|
1351
1392
|
# commit. The task branch is then deleted. A worker worktree that still has
|
|
1352
1393
|
# the task branch checked out moves back to its worker branch first.
|
|
1353
1394
|
class Land
|
|
1354
|
-
def initialize(root, task)
|
|
1355
|
-
@root, @task = root, task
|
|
1395
|
+
def initialize(root, task, base)
|
|
1396
|
+
@root, @task, @base = root, task, base
|
|
1356
1397
|
end
|
|
1357
1398
|
|
|
1358
1399
|
def run(subject)
|
|
@@ -1381,8 +1422,14 @@ end
|
|
|
1381
1422
|
return nil unless Git.own_commits?(@root, goal_branch, branch)
|
|
1382
1423
|
raise Error, STALE % { branch: branch, goal: goal_branch } unless Git.ancestor?(@root, goal_branch, branch)
|
|
1383
1424
|
|
|
1425
|
+
parent = base_parent
|
|
1384
1426
|
merge_squash
|
|
1385
|
-
commit(SquashMessage.for(@task, subject))
|
|
1427
|
+
commit(SquashMessage.for(@task, subject), parent)
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
def base_parent
|
|
1431
|
+
held = Git.out(@root, "merge-base", Git.base_ref(@root, @base), branch)
|
|
1432
|
+
held unless held.empty? || Git.ancestor?(@root, held, goal_branch)
|
|
1386
1433
|
end
|
|
1387
1434
|
|
|
1388
1435
|
STALE = "branch %<branch>s lacks the head of %<goal>s. Open a fix task: merge %<goal>s into %<branch>s."
|
|
@@ -1395,14 +1442,30 @@ end
|
|
|
1395
1442
|
end
|
|
1396
1443
|
|
|
1397
1444
|
# coord makes this commit, not the role, so the commit guard does not apply.
|
|
1398
|
-
def commit(message)
|
|
1399
|
-
return nil if Git.run(goal_dir, "diff", "--cached", "--quiet")
|
|
1445
|
+
def commit(message, parent)
|
|
1446
|
+
return nil if Git.run(goal_dir, "diff", "--cached", "--quiet") && parent.nil?
|
|
1447
|
+
|
|
1448
|
+
parent ? merge_commit(message, parent) : squash_commit(message)
|
|
1449
|
+
Git.out(goal_dir, "rev-parse", "--short", "HEAD")
|
|
1450
|
+
end
|
|
1400
1451
|
|
|
1452
|
+
def squash_commit(message)
|
|
1401
1453
|
args = ["git", "-C", goal_dir, "commit", "-q", "--no-verify", "-F", "-"]
|
|
1402
1454
|
IO.popen(args, "w") { |io| io.write(message) }
|
|
1403
1455
|
raise Error, "git commit on #{goal_branch} failed" unless $?.success?
|
|
1456
|
+
end
|
|
1404
1457
|
|
|
1405
|
-
|
|
1458
|
+
def merge_commit(message, parent)
|
|
1459
|
+
head = Git.out(goal_dir, "rev-parse", "HEAD")
|
|
1460
|
+
sha = commit_tree(message, head, parent)
|
|
1461
|
+
moved = sha && Git.run(goal_dir, "update-ref", "HEAD", sha, head)
|
|
1462
|
+
raise Error, "git commit on #{goal_branch} failed" unless moved
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
def commit_tree(message, *parents)
|
|
1466
|
+
args = ["commit-tree", Git.out(goal_dir, "write-tree"), *parents.flat_map { |sha| ["-p", sha] }, "-F", "-"]
|
|
1467
|
+
sha, status = Open3.capture2("git", "-C", goal_dir, *args, stdin_data: message)
|
|
1468
|
+
sha.strip if status.success?
|
|
1406
1469
|
end
|
|
1407
1470
|
|
|
1408
1471
|
def release_branch
|
|
@@ -1648,6 +1711,18 @@ end
|
|
|
1648
1711
|
raise Error, "git push of #{branch} to #{remote} failed"
|
|
1649
1712
|
end
|
|
1650
1713
|
|
|
1714
|
+
def push_with_base(branch, base)
|
|
1715
|
+
push_missing(base)
|
|
1716
|
+
push(branch)
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
def push_missing(branch)
|
|
1720
|
+
return if !Git.branch?(@root, branch) || Git.remote_branch?(@root, remote, branch)
|
|
1721
|
+
|
|
1722
|
+
push(branch)
|
|
1723
|
+
puts "pushed #{branch}: #{remote} had no branch #{branch}"
|
|
1724
|
+
end
|
|
1725
|
+
|
|
1651
1726
|
def create_pr(head:, base:, title:, body:)
|
|
1652
1727
|
args = ["--head", head, "--base", base, "--title", title, "--reviewer", reviewer, "--body-file", "-"]
|
|
1653
1728
|
gh("pr", "create", "--repo", repo, *args, input: body).lines.last.to_s.strip
|
|
@@ -2086,7 +2161,10 @@ end
|
|
|
2086
2161
|
def timeout = (v = value_of("--timeout")) && v.to_i
|
|
2087
2162
|
|
|
2088
2163
|
def require_arg(command, name)
|
|
2089
|
-
|
|
2164
|
+
value = shift
|
|
2165
|
+
abort("usage: coord #{command} #{name}") if value.nil? || value.start_with?("-")
|
|
2166
|
+
|
|
2167
|
+
value
|
|
2090
2168
|
end
|
|
2091
2169
|
|
|
2092
2170
|
def require_text(command)
|
|
@@ -2244,6 +2322,42 @@ end
|
|
|
2244
2322
|
end
|
|
2245
2323
|
end
|
|
2246
2324
|
|
|
2325
|
+
# Block commands hold a task out of coord next, tell the role that can
|
|
2326
|
+
# remove the blocker, and return the task.
|
|
2327
|
+
module BlockCommands
|
|
2328
|
+
BLOCK_KINDS = %w[needs_input dependency capability failing].freeze
|
|
2329
|
+
BLOCK_USAGE = "usage: coord block ID [--kind #{BLOCK_KINDS.join("|")}] TEXT...".freeze
|
|
2330
|
+
|
|
2331
|
+
def cmd_block
|
|
2332
|
+
kind = block_kind(@args.value_of("--kind") || BLOCK_KINDS.first)
|
|
2333
|
+
id = @args.require_arg("block", "ID")
|
|
2334
|
+
block(id, kind, @args.require_text("block"))
|
|
2335
|
+
puts "blocked #{id} (#{kind})"
|
|
2336
|
+
end
|
|
2337
|
+
|
|
2338
|
+
def block(id, kind, text)
|
|
2339
|
+
tasks.annotate(id, "BLOCKED (#{kind}): #{text}")
|
|
2340
|
+
tasks.block(id)
|
|
2341
|
+
event_log.append("block", @worker, "#{id} #{kind} #{text}")
|
|
2342
|
+
notify_block(id, kind, text)
|
|
2343
|
+
end
|
|
2344
|
+
|
|
2345
|
+
def block_kind(kind) = BLOCK_KINDS.include?(kind) ? kind : abort(BLOCK_USAGE)
|
|
2346
|
+
|
|
2347
|
+
def notify_block(id, kind, text)
|
|
2348
|
+
return deliver("architect", @role, "BLOCKED task #{id} (dependency), #{@worker}: #{text}") if kind == "dependency"
|
|
2349
|
+
|
|
2350
|
+
send_escalation(id, "blocked (#{kind}): #{text}")
|
|
2351
|
+
end
|
|
2352
|
+
|
|
2353
|
+
def cmd_unblock
|
|
2354
|
+
id = @args.require_arg("unblock", "ID")
|
|
2355
|
+
tasks.unblock(id)
|
|
2356
|
+
event_log.append("unblock", @worker, id)
|
|
2357
|
+
puts "unblocked #{id}"
|
|
2358
|
+
end
|
|
2359
|
+
end
|
|
2360
|
+
|
|
2247
2361
|
# Claim commands take, release, note, and finish a task.
|
|
2248
2362
|
module ClaimCommands
|
|
2249
2363
|
def cmd_claim
|
|
@@ -2457,8 +2571,9 @@ end
|
|
|
2457
2571
|
event_log.append("broadcast", @worker, text)
|
|
2458
2572
|
end
|
|
2459
2573
|
|
|
2460
|
-
NO_PUSH = "coord: %<role>s has no live session and no message hook.
|
|
2461
|
-
"
|
|
2574
|
+
NO_PUSH = "coord: %<role>s has no live session and no message hook. " \
|
|
2575
|
+
"The message waits until a session of %<role>s starts. " \
|
|
2576
|
+
"Fix: maf start HARNESS %<role>s --dispatch --detach, " \
|
|
2462
2577
|
"or add an executable .maf/coordination/message-hooks/%<role>s.sh"
|
|
2463
2578
|
|
|
2464
2579
|
# A message is pushed by a hook or read by a live session. Without both,
|
|
@@ -2567,7 +2682,7 @@ end
|
|
|
2567
2682
|
def cmd_land
|
|
2568
2683
|
subject = @args.value_of("--subject")
|
|
2569
2684
|
task = find_task!(@args.require_arg("land", "ID"))
|
|
2570
|
-
land = Land.new(main_root, task)
|
|
2685
|
+
land = Land.new(main_root, task, Git.base_branch(main_root, manifest))
|
|
2571
2686
|
commit = locks.with_mutex("land-#{Goals.short(task["goalid"])}", worker: @worker) { land.run(subject) }
|
|
2572
2687
|
record_landing(task, commit, land)
|
|
2573
2688
|
end
|
|
@@ -2590,7 +2705,7 @@ end
|
|
|
2590
2705
|
setup.ensure_taskrc
|
|
2591
2706
|
goals.find!(id)["uuid"]
|
|
2592
2707
|
end
|
|
2593
|
-
def goal_show = goals.show(@args.require_arg("goal show", "ID"))
|
|
2708
|
+
def goal_show = goals.show(@args.require_arg("goal show", "ID"), File.join(paths.coord_dir, "artifacts"))
|
|
2594
2709
|
|
|
2595
2710
|
def goal_sync
|
|
2596
2711
|
goal = goals.find!(@args.require_arg("goal sync", "ID"))
|
|
@@ -2620,11 +2735,18 @@ end
|
|
|
2620
2735
|
raise Error, "base branch #{base} does not exist" unless Git.branch?(main_root, base)
|
|
2621
2736
|
|
|
2622
2737
|
uuid = tasks.add(title: opts[:title], role: Goals::ROLE, ref: opts[:ref])
|
|
2623
|
-
|
|
2738
|
+
open_goal_branch(uuid, base)
|
|
2624
2739
|
event_log.append("goal", @worker, opts[:title])
|
|
2625
2740
|
puts uuid
|
|
2626
2741
|
end
|
|
2627
2742
|
|
|
2743
|
+
def open_goal_branch(uuid, base)
|
|
2744
|
+
Worktree.new(main_root).checkout(Goals.slug(uuid), Goals.branch(uuid), base)
|
|
2745
|
+
return if !Git.run(main_root, "remote", "get-url", "origin") || Git.remote_branch?(main_root, "origin", base)
|
|
2746
|
+
|
|
2747
|
+
warn "coord: origin has no branch #{base}. coord goal pr pushes the local #{base} again."
|
|
2748
|
+
end
|
|
2749
|
+
|
|
2628
2750
|
UNSYNCED = "coord: %<branch>s lacks the head of %<ref>s. Run coord goal sync %<id>s. " \
|
|
2629
2751
|
"Run the merge suite again. Then run coord goal done again."
|
|
2630
2752
|
|
|
@@ -2718,7 +2840,7 @@ end
|
|
|
2718
2840
|
|
|
2719
2841
|
def publish_goal(goal, entry)
|
|
2720
2842
|
branch = Goals.branch(goal["uuid"])
|
|
2721
|
-
github.
|
|
2843
|
+
github.push_with_base(branch, Git.base_branch(main_root, manifest))
|
|
2722
2844
|
push_memory
|
|
2723
2845
|
entry = entry ? update_pr(goal, entry, branch) : { "url" => open_pr(goal, branch), "seen" => [] }
|
|
2724
2846
|
review_state.put(goal["uuid"], entry.merge("head" => Git.out(main_root, "rev-parse", branch)))
|
|
@@ -2805,6 +2927,7 @@ end
|
|
|
2805
2927
|
def cmd_worktree
|
|
2806
2928
|
role = @args.require_arg("worktree", "ROLE")
|
|
2807
2929
|
worker = @args.shift
|
|
2930
|
+
abort "usage: coord worktree ROLE [WORKER]" if worker&.start_with?("-")
|
|
2808
2931
|
warn_missing_role_files(role)
|
|
2809
2932
|
Worktree.new(@project_root).create(role, worker)
|
|
2810
2933
|
end
|
|
@@ -2900,6 +3023,7 @@ end
|
|
|
2900
3023
|
class CLI
|
|
2901
3024
|
include TaskCommands
|
|
2902
3025
|
include ClaimCommands
|
|
3026
|
+
include BlockCommands
|
|
2903
3027
|
include MessageCommands
|
|
2904
3028
|
include InboxCommands
|
|
2905
3029
|
include GoalCommands
|
data/assets/dispatcher
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# dispatcher polls the inbox and the task board on a fixed interval.
|
|
12
12
|
#
|
|
13
13
|
# Usage:
|
|
14
|
-
# dispatcher ROLE (--harness NAME | --command TEMPLATE) [--model M]
|
|
14
|
+
# dispatcher ROLE (--harness NAME | --command TEMPLATE) [--model M] [--effort L]
|
|
15
15
|
# [--skill NAME | --no-skill] [--interval S] [--max-turns N]
|
|
16
16
|
# [--timeout S] [--idle-timeout S] [--grace S]
|
|
17
17
|
# [--completion-signal TEXT] [--abort-signal TEXT]
|
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
# (claude, codex, cursor, hermes, opencode). Each one
|
|
32
32
|
# resumes sessions. --harness or --command is required.
|
|
33
33
|
# --command TEMPLATE Any other harness. A shell command with %{prompt},
|
|
34
|
-
# %{role}, %{skill}, and %{
|
|
34
|
+
# %{role}, %{skill}, %{model}, and %{effort} placeholders, each
|
|
35
35
|
# shell-escaped. The command must exit when done.
|
|
36
36
|
# Sessions are not resumed for custom commands.
|
|
37
37
|
# --model M Model for the agent, in the harness CLI's own format.
|
|
38
|
+
# --effort L Effort level of the model, in the harness CLI's own
|
|
39
|
+
# format (for example high). Default: the harness default.
|
|
38
40
|
# --skill NAME Hermes skill to preload. Default: <project>-<role> when
|
|
39
41
|
# flow.rb generated it.
|
|
40
42
|
# --no-skill Do not preload a skill.
|
|
@@ -99,7 +101,9 @@
|
|
|
99
101
|
# atomic, so a run never starts for a task that another worker took. If the
|
|
100
102
|
# same task set is still unclaimed afterwards, it waits with a doubling
|
|
101
103
|
# backoff (capped at MAX_BACKOFF) before it tries again. A changed task set
|
|
102
|
-
# dispatches at once.
|
|
104
|
+
# dispatches at once. After MAX_ATTEMPTS runs in a row that leave a task
|
|
105
|
+
# claimed and not done, the dispatcher blocks the task (`coord block --kind
|
|
106
|
+
# failing`), which escalates it. A usage limit or an unknown model counts no run.
|
|
103
107
|
#
|
|
104
108
|
# Sessions and the prompt cache: the session ID is saved to
|
|
105
109
|
# .maf/coordination/sessions/<worker>.session and resumed on the next run, so the
|
|
@@ -151,7 +155,7 @@ module Dispatcher
|
|
|
151
155
|
MAX_BACKOFF = 3600
|
|
152
156
|
REPORT_RETRIES = 1
|
|
153
157
|
|
|
154
|
-
Config = Struct.new(:role, :harness, :command, :model, :skill, :no_skill, :interval, :max_turns,
|
|
158
|
+
Config = Struct.new(:role, :harness, :command, :model, :effort, :skill, :no_skill, :interval, :max_turns,
|
|
155
159
|
:timeout, :idle_timeout, :grace, :completion_signal, :abort_signal,
|
|
156
160
|
:cache_window, :max_session_runs, :max_context, :once, :poll_tasks, :worker, :coord_dir,
|
|
157
161
|
:verbose, :lean,
|
|
@@ -318,6 +322,49 @@ module Dispatcher
|
|
|
318
322
|
end
|
|
319
323
|
end
|
|
320
324
|
|
|
325
|
+
# FailureLimit counts the runs in a row that left a task claimed and not
|
|
326
|
+
# done. At MAX_ATTEMPTS it blocks the task, which escalates it. The count is in
|
|
327
|
+
# .maf/coordination/failures/<id>, so a restart keeps it.
|
|
328
|
+
class FailureLimit
|
|
329
|
+
NOTICE = "Task %<id>s is blocked: %<runs>d runs of %<worker>s in a row ended without coord done. " \
|
|
330
|
+
"Last result: %<last>s Fix the cause, then run `coord unblock %<id>s`."
|
|
331
|
+
UNCOUNTED = %i[limited broken].freeze
|
|
332
|
+
|
|
333
|
+
def initialize(config, poller, coord = ->(*args) { CoordCall.run(Main.run_env(config), *args) })
|
|
334
|
+
@config = config
|
|
335
|
+
@poller = poller
|
|
336
|
+
@coord = coord
|
|
337
|
+
@dir = File.join(config.coord_dir, "failures")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def track(ids, outcome)
|
|
341
|
+
return if UNCOUNTED.include?(outcome)
|
|
342
|
+
|
|
343
|
+
open = outcome == :waiting ? [] : @poller.claimed_task_ids
|
|
344
|
+
ids.each { |id| open.include?(id) ? count(id) : clear(id) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def runs(id) = File.exist?(path(id)) ? File.read(path(id)).to_i : 0
|
|
348
|
+
|
|
349
|
+
private
|
|
350
|
+
|
|
351
|
+
def count(id)
|
|
352
|
+
runs = runs(id) + 1
|
|
353
|
+
runs >= MAX_ATTEMPTS ? block(id, runs) : save(id, runs)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def block(id, runs)
|
|
357
|
+
text = format(NOTICE, id: id, runs: runs, worker: @config.worker, last: Log.last.to_s[0, 200])
|
|
358
|
+
Log.line("task #{id}: #{runs} unfinished runs in a row; blocking it")
|
|
359
|
+
@coord.call("block", "--kind", "failing", id, text)
|
|
360
|
+
clear(id)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def save(id, runs) = FileUtils.mkdir_p(@dir) && File.write(path(id), runs.to_s)
|
|
364
|
+
def clear(id) = FileUtils.rm_f(path(id))
|
|
365
|
+
def path(id) = File.join(@dir, id)
|
|
366
|
+
end
|
|
367
|
+
|
|
321
368
|
# Lessons reads the dead ends and corrections of graphify-out/reflections/
|
|
322
369
|
# LESSONS.md for one task, newest first. coord done and coord lesson save the
|
|
323
370
|
# notes that LESSONS.md sums up. A lesson stays when its note cites a node of
|
|
@@ -764,10 +811,11 @@ module Dispatcher
|
|
|
764
811
|
module Generic
|
|
765
812
|
extend Maf::Shared::Harness::Run
|
|
766
813
|
|
|
767
|
-
PLACEHOLDERS = %i[prompt role skill model].freeze
|
|
814
|
+
PLACEHOLDERS = %i[prompt role skill model effort].freeze
|
|
768
815
|
|
|
769
816
|
def self.build_command(config, prompt, _session_id)
|
|
770
|
-
values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model
|
|
817
|
+
values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model,
|
|
818
|
+
effort: config.effort }
|
|
771
819
|
line = PLACEHOLDERS.reduce(config.command) { |cmd, key| cmd.gsub("%{#{key}}") { values[key].to_s.shellescape } }
|
|
772
820
|
["sh", "-c", line]
|
|
773
821
|
end
|
|
@@ -906,7 +954,7 @@ module Dispatcher
|
|
|
906
954
|
def started(session_id, runs)
|
|
907
955
|
save("mode" => "dispatch", "role" => @config.role, "harness" => @config.command ? "command" : @config.harness,
|
|
908
956
|
"pid" => Process.pid, "running" => true, "run_started_at" => now, "session_id" => session_id,
|
|
909
|
-
"session_runs" => runs, "model" => @config.model || read["model"])
|
|
957
|
+
"session_runs" => runs, "model" => @config.model || read["model"], "effort" => @config.effort)
|
|
910
958
|
end
|
|
911
959
|
|
|
912
960
|
def finished(outcome, detail, model)
|
|
@@ -1309,6 +1357,7 @@ module Dispatcher
|
|
|
1309
1357
|
Log.line("usage limit: no new run until #{@pause.until.utc}")
|
|
1310
1358
|
end
|
|
1311
1359
|
def chores = @chores ||= LeadChores.new(@poller, @config.role)
|
|
1360
|
+
def failure_limit = @failure_limit ||= FailureLimit.new(@config, @poller)
|
|
1312
1361
|
|
|
1313
1362
|
def start
|
|
1314
1363
|
Log.line("started: role=#{@config.role} #{harness_label} #{timing_label}")
|
|
@@ -1337,11 +1386,8 @@ module Dispatcher
|
|
|
1337
1386
|
# unknown model returns them without an attempt.
|
|
1338
1387
|
SETTLE = { failed: :release, limited: :restore, broken: :restore }.freeze
|
|
1339
1388
|
|
|
1340
|
-
# Tasks still unclaimed after a message run count as dispatched, so the
|
|
1341
|
-
# next cycle backs off instead of starting another run for them.
|
|
1342
1389
|
def settle(messages, outcome)
|
|
1343
1390
|
@mailbox.public_send(SETTLE.fetch(outcome, :ack), messages)
|
|
1344
|
-
@watch.dispatched(@poller.unclaimed_task_ids) if @config.poll_tasks
|
|
1345
1391
|
true
|
|
1346
1392
|
end
|
|
1347
1393
|
|
|
@@ -1350,7 +1396,7 @@ module Dispatcher
|
|
|
1350
1396
|
ids = @poller.claimed_task_ids
|
|
1351
1397
|
return false unless @resume.due?(ids)
|
|
1352
1398
|
|
|
1353
|
-
dispatch(Prompt.resume(@config.role, ids) + context(ids.first))
|
|
1399
|
+
failure_limit.track(ids, dispatch(Prompt.resume(@config.role, ids) + context(ids.first)))
|
|
1354
1400
|
@resume.dispatched(ids)
|
|
1355
1401
|
true
|
|
1356
1402
|
end
|
|
@@ -1366,7 +1412,7 @@ module Dispatcher
|
|
|
1366
1412
|
end
|
|
1367
1413
|
|
|
1368
1414
|
def run_task(id)
|
|
1369
|
-
dispatch(Prompt.task(@config.role, id) + context(id))
|
|
1415
|
+
failure_limit.track([id], dispatch(Prompt.task(@config.role, id) + context(id)))
|
|
1370
1416
|
true
|
|
1371
1417
|
end
|
|
1372
1418
|
end
|
|
@@ -1374,7 +1420,7 @@ module Dispatcher
|
|
|
1374
1420
|
# Options parses the command line into a Config.
|
|
1375
1421
|
module Options
|
|
1376
1422
|
VALUES = { "--harness NAME" => [:harness, String], "--command TEMPLATE" => [:command, String],
|
|
1377
|
-
"--model M" => [:model, String], "--skill NAME" => [:skill, String],
|
|
1423
|
+
"--model M" => [:model, String], "--effort L" => [:effort, String], "--skill NAME" => [:skill, String],
|
|
1378
1424
|
"--interval S" => [:interval, Integer], "--max-turns N" => [:max_turns, Integer],
|
|
1379
1425
|
"--timeout S" => [:timeout, Integer], "--cache-window S" => [:cache_window, Integer],
|
|
1380
1426
|
"--max-session-runs N" => [:max_session_runs, Integer], "--max-context N" => [:max_context, Integer],
|