maf 0.2.0 → 0.4.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +22 -1
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/coord +145 -21
  6. data/assets/dispatcher +67 -13
  7. data/install.md +6 -1
  8. data/lib/maf/backup.rb +63 -0
  9. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  10. data/lib/maf/bootstrap.rb +4 -0
  11. data/lib/maf/budget.rb +2 -0
  12. data/lib/maf/cli.rb +38 -10
  13. data/lib/maf/flow/effort_report.rb +20 -0
  14. data/lib/maf/flow/generator.rb +2 -1
  15. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  16. data/lib/maf/flow/manifest.rb +1 -1
  17. data/lib/maf/flow/models.rb +11 -0
  18. data/lib/maf/flow/options.rb +29 -3
  19. data/lib/maf/flow/prompt_text.rb +14 -7
  20. data/lib/maf/flow/report.rb +6 -5
  21. data/lib/maf/flow/role_catalog.rb +1 -1
  22. data/lib/maf/flow/role_files.rb +5 -5
  23. data/lib/maf/flow/roster.rb +6 -3
  24. data/lib/maf/flow.rb +3 -1
  25. data/lib/maf/menu.rb +48 -14
  26. data/lib/maf/restore.rb +62 -0
  27. data/lib/maf/setup_agent/args.rb +16 -10
  28. data/lib/maf/setup_agent/dispatch.rb +2 -2
  29. data/lib/maf/setup_agent/launcher.rb +6 -4
  30. data/lib/maf/setup_agent/manifest.rb +9 -3
  31. data/lib/maf/setup_agent.rb +11 -11
  32. data/lib/maf/shared/harness/defaults.rb +10 -3
  33. data/lib/maf/shared/harnesses/claude.rb +6 -3
  34. data/lib/maf/shared/harnesses/codex.rb +39 -10
  35. data/lib/maf/shared/harnesses/cursor.rb +60 -4
  36. data/lib/maf/shared/harnesses/hermes.rb +21 -11
  37. data/lib/maf/shared/harnesses/opencode.rb +25 -2
  38. data/lib/maf/shared/roles.rb +4 -0
  39. data/lib/maf/shared/sandbox/seatbelt.rb +5 -1
  40. data/lib/maf/shared/sandbox.rb +6 -0
  41. data/lib/maf/team.rb +15 -6
  42. data/lib/maf/team_command.rb +11 -2
  43. data/lib/maf/untrack.rb +12 -1
  44. data/lib/maf/version.rb +1 -1
  45. data/lib/maf/worker_control.rb +12 -1
  46. data/lib/maf/worker_list.rb +83 -0
  47. data/templates/roles.yml +14 -0
  48. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a374ca278aa9cd10c03f38a12fa6bc24956037e9be7d7f5cbc1b64ae5feb7649
4
- data.tar.gz: 73a993337895a9981a134ffe7c95cf10c7134e6c314037168bf0b0e44f9c0f43
3
+ metadata.gz: a2844c9f128a0b025a9a1c933091642b9e885f069d3bfe7daff77f2fe3659c56
4
+ data.tar.gz: c464cfff417c58d40add531944b4ec12a93a5251510dc01d7f2d81a0a7905997
5
5
  SHA512:
6
- metadata.gz: 84e490da67016e6a30344fc07645300ebbc81b37363161726ba29138d5108e2dc762a9cc022918e0e6b5bdfed8382bfda7236dd18690602e55c5e1d8a1155965
7
- data.tar.gz: fb54b0eb031e1d42150a898c84cf6176490b92be13f0619c90fe0094cc67c84b045d2027abc6dbc7bd5a8217935208a748cfa29e769150b746fa210334ecab97
6
+ metadata.gz: d906ee9673550164f3c61c195eee88a41173f80443176c5be7809a89d2da32036f8c6ad03620ca8ddadb8994a345a565602b660e0a150563d4411c6c0e896fae
7
+ data.tar.gz: 3fe01e2df87c0676ced712531aa44ec1935d8a5d5982d1521e765a3d846a4d9fd524485076738ba1c35927f8eb2cb00fea6db174d3fb570ada0565b19ebdeff1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-10-21
6
+
7
+ - Fix: in the macOS sandbox, a worker that the project manager started failed on each run with
8
+ `sandbox_apply: Operation not permitted`. macOS cannot start a sandbox inside a sandbox. The
9
+ sandbox now sets `MAF_SEATBELT=1`. In the sandbox, `maf prepare --dispatch` prepares the worker
10
+ and prints the start commands, and a dispatcher stops at once with the reason in its log.
11
+ - The project manager no longer adds workers on its own. Without a team budget, `maf prepare`
12
+ refuses when an agent runs it. Only the user sets the budget: `maf team set` refuses when an
13
+ agent runs it.
14
+ - Backup of the flow config. Each maf command copies `.maf/config.json`, `roles.yml`,
15
+ `workflow.md`, and `sandbox.json` to `.git/maf/backup/` when they changed, and keeps 10
16
+ copies. New command `maf restore [--list] [BACKUP] [--force]` puts them back and runs
17
+ `maf update`. When `.maf/` exists without `config.json`, maf names the backup. Before, a
18
+ merge of the `maf untrack` commit into a branch that still tracked the flow deleted the
19
+ config, and the agents, `base_branch`, and `github` settings were lost.
20
+
21
+ ## [0.3.0] - 2026-10-21
22
+
23
+ - Fix: each dispatched Hermes run failed. Hermes 0.22 refuses `--run-budget` with `--format
24
+ stream-json`. maf no longer passes `--run-budget`. The `--timeout` of the dispatcher still
25
+ stops a run.
26
+ - Fix: in a Hermes run, `coord` did not get `COORD_WORKER` and the other flow variables, so
27
+ `coord done` refused to close the task. Hermes runs each session in a shared gateway and
28
+ passes only the names in `terminal.client_env_passthrough`. `maf add` and `maf update` print
29
+ the `hermes config set` command that adds the flow names to that list.
30
+ - `maf worker stop|start|restart|status ROLE` finds the worker when the role has one worker,
31
+ for example `tester-bot`.
32
+ - `maf worker list` names the stopped workers also when no worker runs.
33
+ - A worker does not start a team of its own. `.maf/claude/settings.json` sets
34
+ `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0`, so a subagent that Claude names in a `maf start`
35
+ session does not start as a teammate. `maf update` adds the setting.
36
+ - Hermes: each role gets a toolset list. A role that edits no longer gets `kanban`,
37
+ `delegation`, `cronjob`, `computer_use`, and the other toolsets outside the list.
38
+ - Fix: a Hermes role with `can_edit: false` did not get the graph MCP server, because its
39
+ toolset list did not name `mcp-graphify-<project>`.
40
+ - The dispatcher blocks a task after 3 runs in a row that leave it claimed and not done, and
41
+ escalates it to the project manager. Before, it resumed such a task each hour without end.
42
+ New commands: `coord block ID [--kind K] TEXT` holds a task out of `coord next`, and
43
+ `coord unblock ID` returns it. `coord status` and `coord board` show blocked tasks.
44
+ - A worker that cannot finish a task blocks it with a kind: `needs_input`, `dependency`, or
45
+ `capability`. coord tells the architect (dependency) or the project manager (the other kinds).
46
+ Before, the worker kept the claim, and the dispatcher resumed the task each hour.
47
+ - `maf worker list` shows each running worker: its role, harness, mode (session or dispatch),
48
+ pid, and start time. A worker that runs twice, for example as a session and as a
49
+ dispatcher, gets a warning. The last line names the registered workers that do not run.
50
+ - Effort levels per role (#44): `maf add --effort ROLE=LEVEL`, `maf start` and `maf prepare
51
+ --effort LEVEL`, and `dispatcher --effort LEVEL`. maf passes the level to the harness in its
52
+ own form: `--effort` (Claude Code), `model_reasoning_effort` (Codex), `--reasoning` (Hermes),
53
+ `--variant` (opencode), and the model name for Cursor (`gpt-5.3-codex` + `high` =
54
+ `gpt-5.3-codex-high`). Each role has a default level in `roles.yml` (`effort:`), used when the
55
+ harness lists it. After `maf update`, the claude, codex, hermes, and cursor agents get the
56
+ default of their role. The interactive mode shows the levels of each model and asks for one.
57
+ - Fix: Codex 0.163 ran the commands of a worker in its shared background server. The commands
58
+ got the environment of the shell that started the server, not of the worker: no `.maf/bin` on
59
+ PATH, no `COORD_ROLE` and `COORD_WORKER`. maf now starts Codex with `--no-daemon`.
60
+ - Fix: after a message run, the dispatcher counted the unclaimed tasks as dispatched and backed
61
+ off: 2 minutes, then 4, then more. A message run must not claim a task, so the task waited
62
+ without a reason. The task now runs in the next cycle.
63
+
5
64
  ## [0.2.0] - 2026-10-14
6
65
 
7
66
  - 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,10 +187,13 @@ 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/
193
194
  untrack.rb # maf untrack: removes an older install from git
195
+ backup.rb # copies of the flow config in .git/maf/backup
196
+ restore.rb # maf restore: puts the flow config back from a backup
194
197
  local_exclude.rb # the flow block in .git/info/exclude
195
198
  shared/ # stdlib-only code that the maf CLI and the scripts share (installed as .maf/lib/maf/shared/)
196
199
  shared/harness/ # the harness registry, the default of each harness method, shared modules
@@ -231,7 +234,9 @@ multi_agent_flow/
231
234
  hook_session_test.rb # tests for session isolation
232
235
  hook_config_test.rb # tests for project hooks
233
236
  worker_control_test.rb # tests for maf worker
237
+ worker_list_test.rb # tests for maf worker list
234
238
  untrack_test.rb # tests for maf untrack
239
+ backup_test.rb # tests for the backup and maf restore
235
240
  analyst_test.rb # tests for the analyst
236
241
  shared_test.rb # tests for lib/maf/shared/
237
242
  gemspec_test.rb # tests that the gem holds every runtime file
@@ -336,6 +341,20 @@ them when you run a test file directly, so a test never writes to that board:
336
341
  env -u TASKRC -u COORD_DIR -u COORD_ROLE -u COORD_WORKER ruby test/coord_test.rb
337
342
  ```
338
343
 
344
+ `rake mutant` runs the mutation tests with [mutant](https://github.com/mbj/mutant).
345
+ Mutant changes the code in `lib/` and runs the tests again. A change that no test
346
+ finds is an alive mutation: add a test for it. Mutant needs Ruby 3.3 or later.
347
+ It is free for open source projects (`usage: opensource` in `.mutant.yml`).
348
+
349
+ ```sh
350
+ bundle exec rake mutant # the subjects in .mutant.yml
351
+ bundle exec rake mutant SUBJECT='Maf::Budget*' # other subjects
352
+ ```
353
+
354
+ Mutant runs only the test classes that name their code with `cover`. To add a
355
+ subject, require `test/mutant_cover.rb` in the test file, add `cover "Maf::X*"` to
356
+ the test class, and add `Maf::X*` to `matcher.subjects` in `.mutant.yml`.
357
+
339
358
  ```sh
340
359
  ruby test/coord_test.rb # covers the coord CLI
341
360
  ruby test/installer_test.rb # covers bootstrap.rb, flow.rb, setup_agent.rb
@@ -353,7 +372,9 @@ ruby test/dispatcher_test.rb # covers the dispatcher
353
372
  ruby test/board_watch_test.rb # covers the board watcher
354
373
  ruby test/context_watch_test.rb # covers the context-watch hook
355
374
  ruby test/worker_control_test.rb # covers maf worker
375
+ ruby test/worker_list_test.rb # covers maf worker list
356
376
  ruby test/untrack_test.rb # covers maf untrack
377
+ ruby test/backup_test.rb # covers the backup and maf restore
357
378
  ruby test/analyst_test.rb # covers the analyst
358
379
  ruby test/shared_test.rb # covers lib/maf/shared/
359
380
  ```
@@ -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.waiting?(task)
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.waiting?(task) && !task["start"] },
1101
- "In progress" => ->(task) { !Schedule.waiting?(task) && task["start"] },
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
- raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
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
- Git.out(goal_dir, "rev-parse", "--short", "HEAD")
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
- shift || abort("usage: coord #{command} #{name}")
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. The message waits until a %<role>s " \
2461
- "session starts. Fix: maf start HARNESS %<role>s --dispatch --detach, " \
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
- Worktree.new(main_root).checkout(Goals.slug(uuid), Goals.branch(uuid), base)
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.push(branch)
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