maf 0.1.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +29 -11
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/analyst +29 -51
  6. data/assets/coord +168 -41
  7. data/assets/dashboard +1 -5
  8. data/assets/dispatcher +127 -298
  9. data/assets/harness-hooks/context-watch.rb +5 -4
  10. data/assets/lima/maf.yaml.erb +61 -0
  11. data/install.md +6 -1
  12. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  13. data/lib/maf/bootstrap/script_planner.rb +1 -1
  14. data/lib/maf/bootstrap.rb +4 -0
  15. data/lib/maf/cli.rb +22 -9
  16. data/lib/maf/flow/agent_links.rb +2 -2
  17. data/lib/maf/flow/bootstrapper.rb +5 -5
  18. data/lib/maf/flow/codex_hooks.rb +12 -1
  19. data/lib/maf/flow/effort_report.rb +20 -0
  20. data/lib/maf/flow/generator.rb +3 -2
  21. data/lib/maf/flow/harness_linker.rb +2 -2
  22. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  23. data/lib/maf/flow/hook_files.rb +6 -0
  24. data/lib/maf/flow/hook_installer.rb +6 -20
  25. data/lib/maf/flow/manifest.rb +3 -3
  26. data/lib/maf/flow/mcp_config.rb +8 -22
  27. data/lib/maf/flow/mcp_installer.rb +3 -3
  28. data/lib/maf/flow/models.rb +14 -38
  29. data/lib/maf/flow/options.rb +33 -5
  30. data/lib/maf/flow/prompt_builder.rb +7 -14
  31. data/lib/maf/flow/prompt_text.rb +13 -14
  32. data/lib/maf/flow/report.rb +8 -9
  33. data/lib/maf/flow/role_catalog.rb +1 -1
  34. data/lib/maf/flow/role_files.rb +7 -22
  35. data/lib/maf/flow/roster.rb +6 -3
  36. data/lib/maf/flow/validator.rb +2 -2
  37. data/lib/maf/flow.rb +7 -21
  38. data/lib/maf/menu.rb +49 -15
  39. data/lib/maf/migrate/role_files.rb +3 -1
  40. data/lib/maf/retire.rb +42 -6
  41. data/lib/maf/setup_agent/args.rb +16 -10
  42. data/lib/maf/setup_agent/dispatch.rb +3 -3
  43. data/lib/maf/setup_agent/launcher.rb +16 -65
  44. data/lib/maf/setup_agent/manifest.rb +9 -3
  45. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  46. data/lib/maf/setup_agent/role_file.rb +1 -1
  47. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  48. data/lib/maf/setup_agent.rb +11 -13
  49. data/lib/maf/shared/harness/base.rb +57 -0
  50. data/lib/maf/shared/harness/defaults.rb +147 -0
  51. data/lib/maf/shared/harness/output.rb +55 -0
  52. data/lib/maf/shared/harness/traits.rb +35 -0
  53. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  54. data/lib/maf/shared/harness.rb +12 -0
  55. data/lib/maf/shared/harnesses/claude.rb +137 -0
  56. data/lib/maf/shared/harnesses/codex.rb +202 -0
  57. data/lib/maf/shared/harnesses/cursor.rb +120 -0
  58. data/lib/maf/shared/harnesses/hermes.rb +122 -0
  59. data/lib/maf/shared/harnesses/opencode.rb +192 -0
  60. data/lib/maf/shared/processes.rb +20 -0
  61. data/lib/maf/shared/roles.rb +4 -0
  62. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  63. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  64. data/lib/maf/shared/sandbox/links.rb +28 -0
  65. data/lib/maf/shared/sandbox/paths.rb +124 -0
  66. data/lib/maf/shared/sandbox/references.rb +64 -0
  67. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  68. data/lib/maf/shared/sandbox.rb +84 -0
  69. data/lib/maf/team.rb +5 -4
  70. data/lib/maf/team_command.rb +5 -3
  71. data/lib/maf/uninstall/manifest.rb +10 -8
  72. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  73. data/lib/maf/uninstall/notes.rb +13 -2
  74. data/lib/maf/uninstall/role_files.rb +11 -10
  75. data/lib/maf/uninstall/runner.rb +4 -3
  76. data/lib/maf/uninstall/scripts.rb +1 -1
  77. data/lib/maf/uninstall/vm_files.rb +16 -0
  78. data/lib/maf/uninstall.rb +20 -5
  79. data/lib/maf/untrack.rb +12 -25
  80. data/lib/maf/version.rb +1 -1
  81. data/lib/maf/vm/login.rb +52 -0
  82. data/lib/maf/vm/options.rb +41 -0
  83. data/lib/maf/vm/template.rb +44 -0
  84. data/lib/maf/vm.rb +94 -0
  85. data/lib/maf/worker_control.rb +16 -8
  86. data/lib/maf/worker_list.rb +83 -0
  87. data/lib/maf/workers.rb +27 -0
  88. data/templates/roles.yml +14 -0
  89. metadata +33 -11
  90. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  91. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  92. data/templates/claude.md.erb +0 -16
  93. data/templates/codex.md.erb +0 -7
  94. data/templates/hermes.md.erb +0 -12
  95. data/templates/opencode.md.erb +0 -24
data/assets/coord CHANGED
@@ -24,6 +24,7 @@ begin
24
24
  require_relative "../lib/maf/shared/roles"
25
25
  require_relative "../lib/maf/shared/git_exclude"
26
26
  require_relative "../lib/maf/shared/git_identity"
27
+ require_relative "../lib/maf/shared/harness"
27
28
  rescue LoadError
28
29
  abort "coord: the maf shared library is missing. Run maf update."
29
30
  end
@@ -53,6 +54,7 @@ module Coord
53
54
  # A claim older than this is presumed abandoned (crashed worker) and can be
54
55
  # taken over by `next`/`claim` without --force. Override with COORD_LEASE_TTL.
55
56
  DEFAULT_LEASE_TTL = 4 * 3600
57
+ BLOCKED_TAG = "blocked"
56
58
  DEFAULT_POLL_INTERVAL = 60
57
59
 
58
60
  # Lead roles plan and talk to the user. They never own a task, so they wait
@@ -85,6 +87,10 @@ module Coord
85
87
  coord conflicts list pending tasks with overlapping scopes
86
88
  coord claim ID [--force] claim for COORD_WORKER (atomic; refused for lead roles)
87
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
88
94
  coord done ID [--force] complete (refused while the task branch lacks the goal head,
89
95
  or while the verify command fails)
90
96
  coord annotate ID TEXT...
@@ -119,7 +125,7 @@ module Coord
119
125
  COMMANDS = {
120
126
  "init" => :cmd_init, "add" => :cmd_add, "next" => :cmd_next,
121
127
  "conflicts" => :cmd_conflicts,
122
- "claim" => :cmd_claim, "unclaim" => :cmd_unclaim,
128
+ "claim" => :cmd_claim, "unclaim" => :cmd_unclaim, "block" => :cmd_block, "unblock" => :cmd_unblock,
123
129
  "done" => :cmd_done, "annotate" => :cmd_annotate, "lesson" => :cmd_lesson,
124
130
  "msg" => :cmd_msg, "inbox" => :cmd_inbox, "await" => :cmd_await, "broadcast" => :cmd_broadcast,
125
131
  "log" => :cmd_log, "hooks" => :cmd_hooks,
@@ -200,7 +206,7 @@ module Coord
200
206
  def ensure_taskrc
201
207
  had_marker = File.exist?(@taskrc) && File.read(@taskrc).include?(MARKER)
202
208
  FileUtils.mkdir_p(File.dirname(@taskrc))
203
- FileUtils.touch(@taskrc)
209
+ FileUtils.touch(@taskrc) unless File.exist?(@taskrc)
204
210
  ensure_data_location(warn_when_external: !had_marker)
205
211
  had_marker ? add_missing_udas : append_uda_block
206
212
  end
@@ -345,6 +351,9 @@ module Coord
345
351
  rescue ArgumentError
346
352
  false
347
353
  end
354
+
355
+ def self.blocked?(task) = Array(task["tags"]).include?(BLOCKED_TAG)
356
+ def self.held?(task) = waiting?(task) || blocked?(task)
348
357
  end
349
358
 
350
359
  # TaskCli runs Taskwarrior and parses its output. No domain logic here.
@@ -407,6 +416,8 @@ module Coord
407
416
  export([id, "export"]).first || {}
408
417
  end
409
418
 
419
+ def with_goal(uuid) = export(["goalid:#{uuid}", "export"])
420
+
410
421
  private
411
422
 
412
423
  def run(*args)
@@ -516,7 +527,7 @@ module Coord
516
527
  end
517
528
 
518
529
  def mine(role, worker)
519
- 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) }
520
531
  end
521
532
 
522
533
  # Returns the prior holder (nil if there was none) so the caller can warn
@@ -536,10 +547,18 @@ module Coord
536
547
  @cli.modify(id, "worker:")
537
548
  end
538
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
+
539
557
  def find(id) = @cli.task(id)
540
558
  # A short id of digits only would read as a task number. The filter avoids that.
541
559
  def find_short(short) = @cli.task("uuid.startswith:#{short}")
542
560
  def for_goal(uuid) = pending.select { |task| task["goalid"] == uuid }
561
+ def any_for_goal?(uuid) = @cli.with_goal(uuid).any?
543
562
  def done(id) = @cli.done(id)
544
563
  def annotate(id, text) = @cli.annotate(id, text)
545
564
  def pending = @pending ||= @cli.pending
@@ -547,7 +566,7 @@ module Coord
547
566
 
548
567
  private
549
568
 
550
- def actionable?(task) = !Schedule.waiting?(task)
569
+ def actionable?(task) = !Schedule.held?(task)
551
570
 
552
571
  def held_by?(task, worker) = !worker.to_s.empty? && task["worker"] == worker
553
572
 
@@ -840,20 +859,7 @@ module Coord
840
859
  entries.each { |entry| puts line(entry) }
841
860
  end
842
861
 
843
- LEGACY_LIVE_WINDOW = 3600
844
-
845
- def self.live_entry?(entry)
846
- return false unless Maf::Shared::Processes.alive?(entry["pid"])
847
-
848
- recorded = entry["started"].to_s
849
- recorded.empty? ? legacy_live?(entry) : recorded == Maf::Shared::Processes.started_at(entry["pid"])
850
- end
851
-
852
- def self.legacy_live?(entry)
853
- Time.now - Time.parse(entry["seen_at"].to_s) < LEGACY_LIVE_WINDOW
854
- rescue ArgumentError
855
- false
856
- end
862
+ def self.live_entry?(entry) = Maf::Shared::Processes.live?(entry)
857
863
 
858
864
  private
859
865
 
@@ -1109,9 +1115,10 @@ end
1109
1115
  # Board renders status summaries and the Obsidian kanban file.
1110
1116
  class Board
1111
1117
  SECTIONS = {
1112
- "Backlog" => ->(task) { !Schedule.waiting?(task) && !task["start"] },
1113
- "In progress" => ->(task) { !Schedule.waiting?(task) && task["start"] },
1114
- "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) }
1115
1122
  }.freeze
1116
1123
 
1117
1124
  def initialize(tasks, paths)
@@ -1161,6 +1168,7 @@ end
1161
1168
  end
1162
1169
 
1163
1170
  def bucket(task)
1171
+ return "blocked" if Schedule.blocked?(task)
1164
1172
  return "waiting" if Schedule.waiting?(task)
1165
1173
 
1166
1174
  task["start"] ? "in-progress" : "backlog"
@@ -1197,6 +1205,8 @@ end
1197
1205
  def self.run(dir, *args) = system("git", "-C", dir, *args, out: File::NULL, err: File::NULL)
1198
1206
  def self.branch?(dir, name) = run(dir, "show-ref", "--verify", "--quiet", "refs/heads/#{name}")
1199
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)
1200
1210
  def self.out(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read).strip
1201
1211
  def self.ancestor?(dir, older, newer) = run(dir, "merge-base", "--is-ancestor", older, newer)
1202
1212
  def self.own_commits?(dir, base, branch) = out(dir, "rev-list", "--count", "#{base}..#{branch}").to_i.positive?
@@ -1257,11 +1267,12 @@ end
1257
1267
  goals.each { |goal| puts goal_line(goal) }
1258
1268
  end
1259
1269
 
1260
- def show(id)
1270
+ def show(id, artifacts_dir)
1261
1271
  goal = find!(id)
1262
1272
  puts goal_line(goal)
1263
1273
  list = @tasks.for_goal(goal["uuid"])
1264
1274
  list.empty? ? puts(" no open tasks") : list.each { |task| puts task_line(task) }
1275
+ plan_hint(goal["uuid"], artifacts_dir)
1265
1276
  end
1266
1277
 
1267
1278
  def done(id)
@@ -1278,6 +1289,13 @@ end
1278
1289
 
1279
1290
  private
1280
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
+
1281
1299
  def goal_line(goal)
1282
1300
  open = @tasks.for_goal(goal["uuid"]).size
1283
1301
  [goal["uuid"], goal["description"], self.class.branch(goal["uuid"]), "open=#{open}"].join("\t")
@@ -1298,7 +1316,7 @@ end
1298
1316
  end
1299
1317
 
1300
1318
  def start(uuid, base)
1301
- raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
1319
+ refuse_dirty!
1302
1320
 
1303
1321
  branch = "task/#{Goals.short(uuid)}"
1304
1322
  raise Error, "git checkout #{branch} failed" unless checkout(branch, base)
@@ -1308,6 +1326,17 @@ end
1308
1326
 
1309
1327
  private
1310
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
+
1311
1340
  def checkout(branch, base)
1312
1341
  return Git.run(@dir, "checkout", "-q", branch) if Git.branch?(@dir, branch)
1313
1342
 
@@ -1363,8 +1392,8 @@ end
1363
1392
  # commit. The task branch is then deleted. A worker worktree that still has
1364
1393
  # the task branch checked out moves back to its worker branch first.
1365
1394
  class Land
1366
- def initialize(root, task)
1367
- @root, @task = root, task
1395
+ def initialize(root, task, base)
1396
+ @root, @task, @base = root, task, base
1368
1397
  end
1369
1398
 
1370
1399
  def run(subject)
@@ -1393,8 +1422,14 @@ end
1393
1422
  return nil unless Git.own_commits?(@root, goal_branch, branch)
1394
1423
  raise Error, STALE % { branch: branch, goal: goal_branch } unless Git.ancestor?(@root, goal_branch, branch)
1395
1424
 
1425
+ parent = base_parent
1396
1426
  merge_squash
1397
- 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)
1398
1433
  end
1399
1434
 
1400
1435
  STALE = "branch %<branch>s lacks the head of %<goal>s. Open a fix task: merge %<goal>s into %<branch>s."
@@ -1407,14 +1442,30 @@ end
1407
1442
  end
1408
1443
 
1409
1444
  # coord makes this commit, not the role, so the commit guard does not apply.
1410
- def commit(message)
1411
- 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?
1412
1447
 
1448
+ parent ? merge_commit(message, parent) : squash_commit(message)
1449
+ Git.out(goal_dir, "rev-parse", "--short", "HEAD")
1450
+ end
1451
+
1452
+ def squash_commit(message)
1413
1453
  args = ["git", "-C", goal_dir, "commit", "-q", "--no-verify", "-F", "-"]
1414
1454
  IO.popen(args, "w") { |io| io.write(message) }
1415
1455
  raise Error, "git commit on #{goal_branch} failed" unless $?.success?
1456
+ end
1416
1457
 
1417
- 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?
1418
1469
  end
1419
1470
 
1420
1471
  def release_branch
@@ -1660,6 +1711,18 @@ end
1660
1711
  raise Error, "git push of #{branch} to #{remote} failed"
1661
1712
  end
1662
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
+
1663
1726
  def create_pr(head:, base:, title:, body:)
1664
1727
  args = ["--head", head, "--base", base, "--title", title, "--reviewer", reviewer, "--body-file", "-"]
1665
1728
  gh("pr", "create", "--repo", repo, *args, input: body).lines.last.to_s.strip
@@ -1823,11 +1886,17 @@ end
1823
1886
  def write(dir, slug)
1824
1887
  slot = Slots.new(@coord_dir).assign(slug)
1825
1888
  FileUtils.mkdir_p(File.join(dir, MAF_DIR))
1826
- File.write(File.join(dir, FILE), base_lines(slot) + hook_lines(dir, slot))
1889
+ save(File.join(dir, FILE), base_lines(slot) + hook_lines(dir, slot))
1827
1890
  end
1828
1891
 
1829
1892
  private
1830
1893
 
1894
+ def save(file, text)
1895
+ File.write(file, text) unless File.exist?(file) && File.read(file) == text
1896
+ rescue SystemCallError => e
1897
+ warn "coord: cannot write #{file} (#{e.message}). Run coord worktree outside the sandbox to update it."
1898
+ end
1899
+
1831
1900
  def base_lines(slot)
1832
1901
  "export COORD_DIR=#{@coord_dir}\nexport TASKRC=#{File.join(@coord_dir, "taskrc")}\n" \
1833
1902
  "export COORD_SLOT=#{slot}\n#{bin_lines}"
@@ -1913,10 +1982,11 @@ end
1913
1982
  # slash, so it also matches the symlink.
1914
1983
  GRAPH_DIR = "graphify-out"
1915
1984
  LOCAL_EXCLUDES = [ENV_FILE, "/#{GRAPH_DIR}"].freeze
1916
- RUNTIME_FILES = %w[.maf/bin/coord .maf/coordination/harness-hooks/next-task.rb
1917
- .maf/coordination/harness-hooks/board-watch.rb .maf/coordination/harness-hooks/session-guard.rb
1918
- .maf/coordination/harness-hooks/context-watch.rb .codex/hooks.json
1919
- .opencode/plugins/board-watch.js].freeze
1985
+ # The hook files of the flow, and the files that each harness writes in a worktree.
1986
+ RUNTIME_FILES = (%w[.maf/bin/coord .maf/coordination/harness-hooks/next-task.rb
1987
+ .maf/coordination/harness-hooks/board-watch.rb .maf/coordination/harness-hooks/session-guard.rb
1988
+ .maf/coordination/harness-hooks/context-watch.rb] +
1989
+ Maf::Shared::Harness.all.flat_map(&:runtime_files)).freeze
1920
1990
 
1921
1991
  def self.dir_for(root, slug) = Maf::Shared::Project.worktree_dir(root, slug)
1922
1992
 
@@ -2025,6 +2095,14 @@ end
2025
2095
 
2026
2096
  FileUtils.mkdir_p(File.dirname(dest))
2027
2097
  FileUtils.cp(src, dest, preserve: true)
2098
+ rescue Errno::EPERM, Errno::EROFS => e
2099
+ blocked(src, e)
2100
+ end
2101
+
2102
+ def blocked(src, error)
2103
+ raise error unless src.start_with?(File.join(@root, MAF_DIR, ""))
2104
+
2105
+ abort "coord: cannot copy #{src} into the worktree in the sandbox. Run coord worktree on the host."
2028
2106
  end
2029
2107
 
2030
2108
  # The graph is not committed. A relative symlink gives the worktree the
@@ -2083,7 +2161,10 @@ end
2083
2161
  def timeout = (v = value_of("--timeout")) && v.to_i
2084
2162
 
2085
2163
  def require_arg(command, name)
2086
- shift || abort("usage: coord #{command} #{name}")
2164
+ value = shift
2165
+ abort("usage: coord #{command} #{name}") if value.nil? || value.start_with?("-")
2166
+
2167
+ value
2087
2168
  end
2088
2169
 
2089
2170
  def require_text(command)
@@ -2241,6 +2322,42 @@ end
2241
2322
  end
2242
2323
  end
2243
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
+
2244
2361
  # Claim commands take, release, note, and finish a task.
2245
2362
  module ClaimCommands
2246
2363
  def cmd_claim
@@ -2454,8 +2571,9 @@ end
2454
2571
  event_log.append("broadcast", @worker, text)
2455
2572
  end
2456
2573
 
2457
- NO_PUSH = "coord: %<role>s has no live session and no message hook. The message waits until a %<role>s " \
2458
- "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, " \
2459
2577
  "or add an executable .maf/coordination/message-hooks/%<role>s.sh"
2460
2578
 
2461
2579
  # A message is pushed by a hook or read by a live session. Without both,
@@ -2564,7 +2682,7 @@ end
2564
2682
  def cmd_land
2565
2683
  subject = @args.value_of("--subject")
2566
2684
  task = find_task!(@args.require_arg("land", "ID"))
2567
- land = Land.new(main_root, task)
2685
+ land = Land.new(main_root, task, Git.base_branch(main_root, manifest))
2568
2686
  commit = locks.with_mutex("land-#{Goals.short(task["goalid"])}", worker: @worker) { land.run(subject) }
2569
2687
  record_landing(task, commit, land)
2570
2688
  end
@@ -2587,7 +2705,7 @@ end
2587
2705
  setup.ensure_taskrc
2588
2706
  goals.find!(id)["uuid"]
2589
2707
  end
2590
- 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"))
2591
2709
 
2592
2710
  def goal_sync
2593
2711
  goal = goals.find!(@args.require_arg("goal sync", "ID"))
@@ -2617,11 +2735,18 @@ end
2617
2735
  raise Error, "base branch #{base} does not exist" unless Git.branch?(main_root, base)
2618
2736
 
2619
2737
  uuid = tasks.add(title: opts[:title], role: Goals::ROLE, ref: opts[:ref])
2620
- Worktree.new(main_root).checkout(Goals.slug(uuid), Goals.branch(uuid), base)
2738
+ open_goal_branch(uuid, base)
2621
2739
  event_log.append("goal", @worker, opts[:title])
2622
2740
  puts uuid
2623
2741
  end
2624
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
+
2625
2750
  UNSYNCED = "coord: %<branch>s lacks the head of %<ref>s. Run coord goal sync %<id>s. " \
2626
2751
  "Run the merge suite again. Then run coord goal done again."
2627
2752
 
@@ -2715,7 +2840,7 @@ end
2715
2840
 
2716
2841
  def publish_goal(goal, entry)
2717
2842
  branch = Goals.branch(goal["uuid"])
2718
- github.push(branch)
2843
+ github.push_with_base(branch, Git.base_branch(main_root, manifest))
2719
2844
  push_memory
2720
2845
  entry = entry ? update_pr(goal, entry, branch) : { "url" => open_pr(goal, branch), "seen" => [] }
2721
2846
  review_state.put(goal["uuid"], entry.merge("head" => Git.out(main_root, "rev-parse", branch)))
@@ -2802,6 +2927,7 @@ end
2802
2927
  def cmd_worktree
2803
2928
  role = @args.require_arg("worktree", "ROLE")
2804
2929
  worker = @args.shift
2930
+ abort "usage: coord worktree ROLE [WORKER]" if worker&.start_with?("-")
2805
2931
  warn_missing_role_files(role)
2806
2932
  Worktree.new(@project_root).create(role, worker)
2807
2933
  end
@@ -2897,6 +3023,7 @@ end
2897
3023
  class CLI
2898
3024
  include TaskCommands
2899
3025
  include ClaimCommands
3026
+ include BlockCommands
2900
3027
  include MessageCommands
2901
3028
  include InboxCommands
2902
3029
  include GoalCommands
data/assets/dashboard CHANGED
@@ -137,11 +137,7 @@ end
137
137
  {}
138
138
  end
139
139
 
140
- # A pid can belong to a new process. The recorded start time tells them apart.
141
- def live?(presence)
142
- pid = presence["pid"].to_i
143
- Maf::Shared::Processes.alive?(pid) && [nil, "", Maf::Shared::Processes.started_at(pid)].include?(presence["started"])
144
- end
140
+ def live?(presence) = Maf::Shared::Processes.live?(presence)
145
141
 
146
142
  def tail(name)
147
143
  path = File.join(sessions, name)