maf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +411 -0
  5. data/assets/agents-contract.md +80 -0
  6. data/assets/analyst +240 -0
  7. data/assets/coord +2936 -0
  8. data/assets/dashboard +553 -0
  9. data/assets/dashboard.html +341 -0
  10. data/assets/dispatcher +1687 -0
  11. data/assets/doc-graph-refresh +286 -0
  12. data/assets/env.sh +6 -0
  13. data/assets/git-hooks/post-commit +7 -0
  14. data/assets/git-hooks/post-merge +7 -0
  15. data/assets/git-hooks/pre-commit +32 -0
  16. data/assets/harness-hooks/board-watch-opencode.js +87 -0
  17. data/assets/harness-hooks/board-watch.rb +286 -0
  18. data/assets/harness-hooks/context-watch.rb +268 -0
  19. data/assets/harness-hooks/next-task-hermes.sh +48 -0
  20. data/assets/harness-hooks/next-task.rb +97 -0
  21. data/assets/harness-hooks/session-guard.rb +128 -0
  22. data/assets/taskrc.append +11 -0
  23. data/assets/vault +224 -0
  24. data/assets/worktree-env.example.rb +26 -0
  25. data/exe/maf +14 -0
  26. data/install.md +326 -0
  27. data/lib/maf/bootstrap/claude_settings.rb +55 -0
  28. data/lib/maf/bootstrap/dependencies.rb +37 -0
  29. data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
  30. data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
  31. data/lib/maf/bootstrap/graph_home.rb +62 -0
  32. data/lib/maf/bootstrap/hook_merger.rb +53 -0
  33. data/lib/maf/bootstrap/installer.rb +66 -0
  34. data/lib/maf/bootstrap/layout_planner.rb +18 -0
  35. data/lib/maf/bootstrap/marked_block.rb +44 -0
  36. data/lib/maf/bootstrap/memory_branch.rb +77 -0
  37. data/lib/maf/bootstrap/options.rb +34 -0
  38. data/lib/maf/bootstrap/project.rb +77 -0
  39. data/lib/maf/bootstrap/script_planner.rb +81 -0
  40. data/lib/maf/bootstrap/text_planner.rb +42 -0
  41. data/lib/maf/bootstrap/vault_starter.rb +41 -0
  42. data/lib/maf/bootstrap/writer.rb +69 -0
  43. data/lib/maf/bootstrap.rb +162 -0
  44. data/lib/maf/budget.rb +59 -0
  45. data/lib/maf/cli.rb +135 -0
  46. data/lib/maf/env_exclude.rb +23 -0
  47. data/lib/maf/flow/agent_links.rb +79 -0
  48. data/lib/maf/flow/bootstrapper.rb +36 -0
  49. data/lib/maf/flow/codex_hooks.rb +50 -0
  50. data/lib/maf/flow/generator.rb +63 -0
  51. data/lib/maf/flow/harness_linker.rb +37 -0
  52. data/lib/maf/flow/hermes_hook.rb +48 -0
  53. data/lib/maf/flow/hermes_hook_setup.rb +69 -0
  54. data/lib/maf/flow/hook_files.rb +16 -0
  55. data/lib/maf/flow/hook_installer.rb +33 -0
  56. data/lib/maf/flow/legacy_codex_hook.rb +71 -0
  57. data/lib/maf/flow/manifest.rb +51 -0
  58. data/lib/maf/flow/mcp_config.rb +72 -0
  59. data/lib/maf/flow/mcp_installer.rb +45 -0
  60. data/lib/maf/flow/models.rb +61 -0
  61. data/lib/maf/flow/options.rb +65 -0
  62. data/lib/maf/flow/prompt_builder.rb +85 -0
  63. data/lib/maf/flow/prompt_text.rb +263 -0
  64. data/lib/maf/flow/report.rb +89 -0
  65. data/lib/maf/flow/role_catalog.rb +40 -0
  66. data/lib/maf/flow/role_files.rb +72 -0
  67. data/lib/maf/flow/role_stub.rb +38 -0
  68. data/lib/maf/flow/roster.rb +28 -0
  69. data/lib/maf/flow/validator.rb +38 -0
  70. data/lib/maf/flow/workflow.rb +28 -0
  71. data/lib/maf/flow.rb +84 -0
  72. data/lib/maf/local_exclude.rb +53 -0
  73. data/lib/maf/menu.rb +101 -0
  74. data/lib/maf/migrate/moves.rb +44 -0
  75. data/lib/maf/migrate/rewrites.rb +53 -0
  76. data/lib/maf/migrate/role_files.rb +35 -0
  77. data/lib/maf/migrate/runner.rb +66 -0
  78. data/lib/maf/migrate/worktrees.rb +65 -0
  79. data/lib/maf/migrate.rb +62 -0
  80. data/lib/maf/prompt.rb +40 -0
  81. data/lib/maf/retire.rb +116 -0
  82. data/lib/maf/role_limits.rb +49 -0
  83. data/lib/maf/setup_agent/args.rb +57 -0
  84. data/lib/maf/setup_agent/dispatch.rb +44 -0
  85. data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
  86. data/lib/maf/setup_agent/hermes_skill.rb +26 -0
  87. data/lib/maf/setup_agent/launcher.rb +85 -0
  88. data/lib/maf/setup_agent/manifest.rb +35 -0
  89. data/lib/maf/setup_agent/project.rb +9 -0
  90. data/lib/maf/setup_agent/role_file.rb +30 -0
  91. data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
  92. data/lib/maf/setup_agent/worktree.rb +50 -0
  93. data/lib/maf/setup_agent.rb +111 -0
  94. data/lib/maf/shared/git_exclude.rb +33 -0
  95. data/lib/maf/shared/git_identity.rb +41 -0
  96. data/lib/maf/shared/peak_rate.rb +20 -0
  97. data/lib/maf/shared/processes.rb +31 -0
  98. data/lib/maf/shared/project.rb +34 -0
  99. data/lib/maf/shared/roles.rb +19 -0
  100. data/lib/maf/team.rb +114 -0
  101. data/lib/maf/team_command.rb +73 -0
  102. data/lib/maf/uninstall/claude_settings.rb +40 -0
  103. data/lib/maf/uninstall/codex_hooks.rb +18 -0
  104. data/lib/maf/uninstall/commit_guard.rb +16 -0
  105. data/lib/maf/uninstall/coordination.rb +15 -0
  106. data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
  107. data/lib/maf/uninstall/git.rb +13 -0
  108. data/lib/maf/uninstall/local_files.rb +33 -0
  109. data/lib/maf/uninstall/manifest.rb +29 -0
  110. data/lib/maf/uninstall/marked_files.rb +37 -0
  111. data/lib/maf/uninstall/mcp_entries.rb +43 -0
  112. data/lib/maf/uninstall/notes.rb +31 -0
  113. data/lib/maf/uninstall/owned.rb +12 -0
  114. data/lib/maf/uninstall/role_files.rb +51 -0
  115. data/lib/maf/uninstall/runner.rb +67 -0
  116. data/lib/maf/uninstall/scripts.rb +35 -0
  117. data/lib/maf/uninstall/vault_watcher.rb +21 -0
  118. data/lib/maf/uninstall/worktrees.rb +30 -0
  119. data/lib/maf/uninstall.rb +59 -0
  120. data/lib/maf/untrack.rb +90 -0
  121. data/lib/maf/version.rb +5 -0
  122. data/lib/maf/worker_archive.rb +63 -0
  123. data/lib/maf/worker_control.rb +137 -0
  124. data/lib/maf/workers.rb +37 -0
  125. data/lib/maf.rb +5 -0
  126. data/templates/claude.md.erb +16 -0
  127. data/templates/codex.md.erb +7 -0
  128. data/templates/hermes.md.erb +12 -0
  129. data/templates/opencode.md.erb +24 -0
  130. data/templates/role-stub.yml.erb +15 -0
  131. data/templates/roles.yml +289 -0
  132. data/templates/workflows/panel.md +20 -0
  133. data/templates/workflows/plan-review.md +9 -0
  134. data/templates/workflows/simple.md +4 -0
  135. data/templates/workflows/tdd.md +8 -0
  136. metadata +193 -0
data/assets/coord ADDED
@@ -0,0 +1,2936 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # coord - shared coordination layer for multi-agent development.
5
+ #
6
+ # Storage: .maf/coordination/ (Taskwarrior board, inbox, locks, exports)
7
+ # Locking: mkdir-based (portable on macOS and Linux; no flock required)
8
+ # Deps: task (Taskwarrior). Ruby parses the JSON itself; no jq needed.
9
+ # Ruby: 3.0+ (uses endless method defs: `def x = expr`). This file will
10
+ # not parse under Ruby 2.x; ensure `ruby` on PATH is 3.0+.
11
+ #
12
+ # Agents should use this wrapper, not raw `task`, so the protocol stays stable.
13
+ require "json"
14
+ require "fileutils"
15
+ require "time"
16
+ require "shellwords"
17
+ require "rbconfig"
18
+ require "optparse"
19
+ require "open3"
20
+ require "pathname"
21
+ begin
22
+ require_relative "../lib/maf/shared/processes"
23
+ require_relative "../lib/maf/shared/project"
24
+ require_relative "../lib/maf/shared/roles"
25
+ require_relative "../lib/maf/shared/git_exclude"
26
+ require_relative "../lib/maf/shared/git_identity"
27
+ rescue LoadError
28
+ abort "coord: the maf shared library is missing. Run maf update."
29
+ end
30
+
31
+ module Coord
32
+ class Error < StandardError; end
33
+
34
+ # NOTE: MARKER is duplicated in bootstrap.rb on purpose. Both scripts run
35
+ # standalone (coord is copied into projects), so they share no load path.
36
+ MARKER = ">>> multi-agent-flow >>>"
37
+ # The one folder that holds every file of the flow in a project.
38
+ MAF_DIR = ".maf"
39
+ UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
40
+
41
+ TASKRC_BLOCK = <<~BLOCK
42
+ # >>> multi-agent-flow >>>
43
+ uda.role.type=string
44
+ uda.worker.type=string
45
+ uda.scope.type=string
46
+ uda.taskref.type=string
47
+ uda.goalid.type=string
48
+ # Hide the "TASKRC override" footnote. coord always sets TASKRC.
49
+ verbose=blank,header,footnote,label,new-id,affected,edit,special,project,sync,filter,recur
50
+ # <<< multi-agent-flow <<<
51
+ BLOCK
52
+
53
+ # A claim older than this is presumed abandoned (crashed worker) and can be
54
+ # taken over by `next`/`claim` without --force. Override with COORD_LEASE_TTL.
55
+ DEFAULT_LEASE_TTL = 4 * 3600
56
+ DEFAULT_POLL_INTERVAL = 60
57
+
58
+ # Lead roles plan and talk to the user. They never own a task, so they wait
59
+ # on their inbox, not on the task board.
60
+ LEADS = Maf::Shared::Roles::LEADS
61
+
62
+ # Usage holds the help text of coord.
63
+ module Usage
64
+ TEXT = <<~TEXT
65
+ coord - multi-agent coordination
66
+
67
+ coord init
68
+ coord add --role R --scope S --title T [--project P] [--ref R] [--goal ID]
69
+ coord goal add --title T [--ref R] [--base BRANCH] create a goal, its branch and its worktree
70
+ coord goal list list open goals
71
+ coord goal show ID show a goal and its tasks
72
+ coord goal sync ID merge the base branch into the goal branch
73
+ coord goal done ID close a goal (refused while a task is open or the goal lacks the base head)
74
+ coord goal pr ID push the goal branch as the GitHub bot and open or update its pull request
75
+ coord review-watch [--once] [--interval S]
76
+ send new reviews of goal pull requests to the architect;
77
+ close a goal and run gc --yes after its pull request merges
78
+ coord land ID [--subject TEXT] squash a done task branch into its goal branch, then delete the task branch
79
+ coord reap [--minutes N] release the claims of workers not seen for N minutes
80
+ (default: team.reap_minutes, else 90; a live dispatcher is never released)
81
+ coord gc [--yes] list (or with --yes delete) merged flow branches and finished goal worktrees
82
+ coord show ID show one task: its fields and its annotations
83
+ coord start-task ID check out branch task/<id> from its goal branch
84
+ coord next [ROLE] [--mine] [--wait [--interval S] [--timeout S]]
85
+ coord conflicts list pending tasks with overlapping scopes
86
+ coord claim ID [--force] claim for COORD_WORKER (atomic; refused for lead roles)
87
+ coord unclaim ID release a claim without finishing it
88
+ coord done ID [--force] complete (refused while the task branch lacks the goal head,
89
+ or while the verify command fails)
90
+ coord annotate ID TEXT...
91
+ coord lesson ID dead_end|corrected TEXT...
92
+ save a lesson of a task to the graph memory: an approach that
93
+ failed (dead_end), or the right way (corrected)
94
+ coord msg [--from A] [--task ID] [--fyi] TO TEXT...
95
+ TO is a role or a worker; a worker's mail goes to its role inbox.
96
+ --task also saves the text as a note on the task.
97
+ --fyi wakes no one: the next run of TO reads the message.
98
+ coord escalate [--task ID] TEXT... report a problem you cannot fix to the project manager
99
+ (copy to the architect)
100
+ coord broadcast [--from A] [--to workers|leads|all] TEXT...
101
+ send to a group of roles (default: workers)
102
+ coord inbox [ROLE] [--peek] [--all] [--wait [--interval S] [--timeout S]]
103
+ coord await [--timeout S] interactive session: arm the stop hook, then end the turn.
104
+ The hook waits for work without model calls (default: 3000s)
105
+ coord hooks [ROLE] show message hooks for a role or all
106
+ coord log [N] show last N coordination events
107
+ coord lock NAME [--ttl SECONDS] [--worker W]
108
+ coord unlock NAME
109
+ coord with-lock NAME [--ttl SECONDS] -- CMD...
110
+ coord worktree ROLE [WORKER] create a git worktree + branch for a role
111
+ coord status
112
+ coord who list workers with their presence (live or gone)
113
+ coord board
114
+ coord export
115
+ coord help
116
+ TEXT
117
+ end
118
+
119
+ COMMANDS = {
120
+ "init" => :cmd_init, "add" => :cmd_add, "next" => :cmd_next,
121
+ "conflicts" => :cmd_conflicts,
122
+ "claim" => :cmd_claim, "unclaim" => :cmd_unclaim,
123
+ "done" => :cmd_done, "annotate" => :cmd_annotate, "lesson" => :cmd_lesson,
124
+ "msg" => :cmd_msg, "inbox" => :cmd_inbox, "await" => :cmd_await, "broadcast" => :cmd_broadcast,
125
+ "log" => :cmd_log, "hooks" => :cmd_hooks,
126
+ "lock" => :cmd_lock, "unlock" => :cmd_unlock, "with-lock" => :cmd_with_lock,
127
+ "worktree" => :cmd_worktree, "goal" => :cmd_goal, "start-task" => :cmd_start_task,
128
+ "land" => :cmd_land, "gc" => :cmd_gc, "reap" => :cmd_reap, "review-watch" => :cmd_review_watch,
129
+ "show" => :cmd_show, "escalate" => :cmd_escalate,
130
+ "status" => :cmd_status, "board" => :cmd_board, "export" => :cmd_export,
131
+ "who" => :cmd_who, "help" => :cmd_help
132
+ }.freeze
133
+
134
+ ADD_OPTS = { "--role" => :role, "--scope" => :scope, "--ref" => :ref,
135
+ "--project" => :project, "--title" => :title, "--goal" => :goal }.freeze
136
+ GOAL_OPTS = { "--title" => :title, "--ref" => :ref, "--base" => :base }.freeze
137
+
138
+ # Wiring lazily builds the collaborators the CLI needs.
139
+ module Wiring
140
+ def paths = @paths ||= Paths.new(@coord_dir)
141
+ def setup = @setup ||= Setup.new(paths, @taskrc)
142
+ def tasks = @tasks ||= Tasks.new
143
+ def conflicts = @conflicts ||= Conflicts.new(tasks)
144
+ def locks = @locks ||= Locks.new(paths)
145
+ def messages = @messages ||= Messages.new(paths, @role)
146
+ def board = @board ||= Board.new(tasks, paths)
147
+ def event_log = @event_log ||= EventLog.new(paths)
148
+ def hooks = @hooks ||= Hooks.new(paths)
149
+ def presence = @presence ||= Presence.new(paths)
150
+ end
151
+
152
+ # Paths resolves every coordination path from one root.
153
+ class Paths
154
+ attr_reader :coord_dir
155
+
156
+ def initialize(coord_dir)
157
+ @coord_dir = coord_dir
158
+ end
159
+
160
+ def inbox_dir(role) = File.join(coord_dir, "inbox", role.to_s)
161
+
162
+ # Folders named <role>-<n>, written by older versions for one worker.
163
+ def worker_inbox_dirs(role)
164
+ pattern = /\A#{Regexp.escape(role.to_s)}-\d+\z/
165
+ Dir.glob(File.join(coord_dir, "inbox", "#{role}-*")).select { |dir| File.basename(dir).match?(pattern) }
166
+ end
167
+ def message_hooks_dir = File.join(coord_dir, "message-hooks")
168
+ def hook_path(role) = File.join(message_hooks_dir, "#{role}.sh")
169
+ def locks_dir = File.join(coord_dir, "locks")
170
+ def exports_dir = File.join(coord_dir, "exports")
171
+ def lock_dir(name) = File.join(locks_dir, "#{name}.d")
172
+ def lock_meta(name) = File.join(lock_dir(name), "meta.json")
173
+ def export_path = File.join(exports_dir, "tasks.json")
174
+ def board_path = File.join(exports_dir, "board.md")
175
+ def taskdata_dir = File.join(coord_dir, "taskdata")
176
+ def log_path = File.join(coord_dir, "events.log")
177
+ def hook_log(role) = File.join(message_hooks_dir, "#{role}.log")
178
+ def presence_dir = File.join(coord_dir, "presence")
179
+ def usage_dir = File.join(coord_dir, "usage")
180
+ def manifest_path = File.join(File.dirname(File.expand_path(coord_dir)), "config.json")
181
+ def vault_path = File.join(File.dirname(File.expand_path(coord_dir)), "bin", "vault")
182
+ end
183
+
184
+ # Setup creates the coordination directories and the Taskwarrior UDAs.
185
+ # The Taskwarrior database is project-local (.maf/coordination/taskdata), not the
186
+ # user's global ~/.task, so two projects never share one board. An explicit
187
+ # TASKRC env var still overrides this (see CLI#initialize).
188
+ class Setup
189
+ SUBDIRS = %w[inbox locks exports message-hooks].freeze
190
+
191
+ def initialize(paths, taskrc)
192
+ @paths = paths
193
+ @taskrc = taskrc
194
+ end
195
+
196
+ def ensure_dirs
197
+ SUBDIRS.each { |sub| FileUtils.mkdir_p(File.join(@paths.coord_dir, sub)) }
198
+ end
199
+
200
+ def ensure_taskrc
201
+ had_marker = File.exist?(@taskrc) && File.read(@taskrc).include?(MARKER)
202
+ FileUtils.mkdir_p(File.dirname(@taskrc))
203
+ FileUtils.touch(@taskrc)
204
+ ensure_data_location(warn_when_external: !had_marker)
205
+ had_marker ? add_missing_udas : append_uda_block
206
+ end
207
+
208
+ private
209
+
210
+ # An explicit TASKRC pointed outside coord_dir (e.g. a user's own
211
+ # ~/.taskrc) is a deliberate choice to use their own database. Only touch
212
+ # data.location in the taskrc this tool created and owns, never redirect
213
+ # someone else's existing Taskwarrior config to a project-local database.
214
+ def project_local?
215
+ File.expand_path(File.dirname(@taskrc)) == File.expand_path(@paths.coord_dir)
216
+ end
217
+
218
+ # Never redirect an external taskrc (e.g. a personal ~/.taskrc), and warn
219
+ # once that it is used as-is. A project-local taskrc this tool owns but
220
+ # that predates the project-local database (marker present, no
221
+ # data.location) would otherwise fall back to the global ~/.task; add the
222
+ # missing data.location in place.
223
+ def ensure_data_location(warn_when_external:)
224
+ return warn_external if warn_when_external && !project_local?
225
+
226
+ write_data_location if project_local? && !has_data_location?
227
+ end
228
+
229
+ def warn_external
230
+ warn "coord: TASKRC=#{@taskrc} is outside .maf/coordination/; using its existing database as-is, " \
231
+ "not redirecting it."
232
+ end
233
+
234
+ def write_data_location
235
+ FileUtils.mkdir_p(@paths.taskdata_dir)
236
+ File.open(@taskrc, "a") do |file|
237
+ file.puts unless file.size.zero?
238
+ file.puts("data.location=#{File.expand_path(@paths.taskdata_dir)}")
239
+ end
240
+ end
241
+
242
+ def append_uda_block
243
+ File.open(@taskrc, "a") do |file|
244
+ file.puts unless file.size.zero?
245
+ file.puts(TASKRC_BLOCK)
246
+ end
247
+ end
248
+
249
+ # A taskrc from an older coord lacks UDAs and settings added later. Taskwarrior reads an
250
+ # unknown NAME:VALUE as description text, so add each missing line.
251
+ def add_missing_udas
252
+ text = File.read(@taskrc)
253
+ missing = TASKRC_BLOCK.lines.grep(/\A(uda\.|verbose=)/).reject { |line| text.include?(line) }
254
+ File.write(@taskrc, text.sub(/^# <<< multi-agent-flow <<<$/) { missing.join + $& }) unless missing.empty?
255
+ end
256
+
257
+ def has_data_location?
258
+ File.exist?(@taskrc) && File.read(@taskrc).match?(/^data\.location=/)
259
+ end
260
+ end
261
+
262
+ # Scopes compares path scopes with a component-prefix heuristic.
263
+ # It understands "dir/**" and exact paths, not {} alternation or mid-globs.
264
+ module Scopes
265
+ GLOB = /[*?\[\{]/
266
+
267
+ def self.overlap?(left, right)
268
+ patterns(left).product(patterns(right)).any? { |a, b| pair_overlap?(a, b) }
269
+ end
270
+
271
+ def self.pair_overlap?(left, right)
272
+ # A top-level glob ("*.rb") has an empty base: an empty prefix matches
273
+ # any path, so treating it like a normal prefix would flag it as
274
+ # overlapping every other scope. Only count it as overlap when the
275
+ # pattern is literally the same.
276
+ return left == right if base(left).empty? || base(right).empty?
277
+
278
+ prefix?(base(left), base(right)) || prefix?(base(right), base(left))
279
+ end
280
+
281
+ def self.base(scope)
282
+ scope.split("/").take_while { |part| part !~ GLOB }
283
+ end
284
+
285
+ def self.prefix?(short, long)
286
+ short == long.first(short.size)
287
+ end
288
+
289
+ def self.patterns(scope)
290
+ scope.to_s.split(",").map(&:strip).reject(&:empty?)
291
+ end
292
+ end
293
+
294
+ # Conflicts finds overlapping scopes among pending tasks and reports them.
295
+ class Conflicts
296
+ def initialize(tasks)
297
+ @tasks = tasks
298
+ end
299
+
300
+ def with(scope, exclude: nil)
301
+ candidates(exclude).select { |task| Scopes.overlap?(task["scope"], scope) }
302
+ end
303
+
304
+ def warn_with(scope, exclude:)
305
+ with(scope, exclude: exclude).each { |task| warn warning(scope, task) }
306
+ end
307
+
308
+ def report
309
+ pairs = all
310
+ return puts("no scope conflicts") if pairs.empty?
311
+
312
+ pairs.each { |a, b| puts line(a, b) }
313
+ end
314
+
315
+ private
316
+
317
+ def all
318
+ @tasks.pending.combination(2).select { |a, b| Scopes.overlap?(a["scope"], b["scope"]) }
319
+ end
320
+
321
+ def candidates(exclude)
322
+ @tasks.pending.reject { |task| task["uuid"] == exclude }
323
+ end
324
+
325
+ def warning(scope, task)
326
+ "coord: scope '#{scope}' overlaps task #{task["uuid"]} " \
327
+ "owned by #{task["role"]} (#{task["scope"]})"
328
+ end
329
+
330
+ def line(a, b)
331
+ "#{a["role"]} #{a["scope"]} (#{a["uuid"]}) <> #{b["role"]} #{b["scope"]} (#{b["uuid"]})"
332
+ end
333
+ end
334
+
335
+ # Tasks wraps the Taskwarrior CLI. Every query is scoped to one task.
336
+ # Schedule interprets Taskwarrior date fields.
337
+ module Schedule
338
+ # Taskwarrior keeps the `wait` field after the date elapses; it only stops
339
+ # filtering the task out of default reports. Compare the date, not presence.
340
+ def self.waiting?(task)
341
+ wait = task["wait"].to_s
342
+ return false if wait.empty?
343
+
344
+ Time.parse(wait) > Time.now
345
+ rescue ArgumentError
346
+ false
347
+ end
348
+ end
349
+
350
+ # TaskCli runs Taskwarrior and parses its output. No domain logic here.
351
+ class TaskCli
352
+ def available?
353
+ @available = system("task", "--version", out: File::NULL, err: File::NULL) if @available.nil?
354
+ @available
355
+ end
356
+
357
+ def require!
358
+ raise Error, "Taskwarrior ('task') is not installed. See README (brew install task)." unless available?
359
+ end
360
+
361
+ # `--` forces every following word into the description, even one that
362
+ # reads like an attribute ("due:tomorrow", "scope:x"). Without it, a title
363
+ # or annotation matching NAME:VALUE for a built-in or registered UDA is
364
+ # parsed as that attribute and the text is silently lost.
365
+ def create(args)
366
+ require!
367
+ output = capture(args)
368
+ extract_uuid(output) || add_failed(output)
369
+ end
370
+
371
+ # The add may have succeeded even when the uuid could not be read back
372
+ # (extract_uuid queries the created task again). Distinguish the two so a
373
+ # caller does not retry and create a duplicate.
374
+ def add_failed(output)
375
+ id = created_id(output)
376
+ raise Error, "task #{id} was created but its uuid could not be read" if id
377
+
378
+ raise Error, "task add failed: #{last_line(output)}"
379
+ end
380
+
381
+ def modify(id, *args)
382
+ run(id, "modify", *args) || raise(Error, "task modify failed")
383
+ end
384
+
385
+ def done(id)
386
+ require!
387
+ run(id, "done") || raise(Error, "task done failed")
388
+ end
389
+
390
+ def stop(id)
391
+ run(id, "stop") || raise(Error, "task stop failed")
392
+ end
393
+
394
+ def annotate(id, text)
395
+ require!
396
+ run(id, "annotate", "--", text) || raise(Error, "annotate failed")
397
+ end
398
+
399
+ # `status:pending` hides waited tasks; include them so the board can show
400
+ # a Waiting column. Waited tasks still export status "pending"; the `wait`
401
+ # field is the discriminator.
402
+ def pending
403
+ export(["(", "status:pending", "or", "status:waiting", ")", "export"])
404
+ end
405
+
406
+ def task(id)
407
+ export([id, "export"]).first || {}
408
+ end
409
+
410
+ private
411
+
412
+ def run(*args)
413
+ system("task", *args, out: File::NULL, err: File::NULL)
414
+ end
415
+
416
+ def capture(args)
417
+ `task #{Shellwords.join(args)} 2>&1`
418
+ end
419
+
420
+ def export(args)
421
+ raw = `task #{Shellwords.join(args)} 2>/dev/null`
422
+ raw.strip.empty? ? [] : JSON.parse(raw)
423
+ rescue JSON::ParserError
424
+ []
425
+ end
426
+
427
+ # Prefer a UUID printed by Taskwarrior; otherwise read the id from
428
+ # "Created task N" and export that one task. Never query the whole database.
429
+ def extract_uuid(output)
430
+ output[UUID_PATTERN] || uuid_from_created(output)
431
+ end
432
+
433
+ def uuid_from_created(output)
434
+ id = created_id(output)
435
+ id && task(id)["uuid"]
436
+ end
437
+
438
+ def created_id(output)
439
+ output[/Created task (\d+)/, 1]
440
+ end
441
+
442
+ def last_line(output)
443
+ output.lines.map(&:strip).reject(&:empty?).last || "no task id returned"
444
+ end
445
+ end
446
+
447
+ # Lease interprets how long a claim is honored without renewal. A claim
448
+ # older than the lease is presumed abandoned (the worker crashed) and
449
+ # becomes claimable again without --force.
450
+ module Lease
451
+ @warned = {}
452
+
453
+ # An unset, empty, non-numeric, or non-positive COORD_LEASE_TTL falls back
454
+ # to the default instead of collapsing to 0 (which would make every claim
455
+ # look instantly expired). Warn once per distinct bad value.
456
+ def self.ttl
457
+ raw = ENV["COORD_LEASE_TTL"].to_s
458
+ return DEFAULT_LEASE_TTL if raw.empty?
459
+
460
+ value = Integer(raw, exception: false)
461
+ value&.positive? ? value : invalid_ttl(raw)
462
+ end
463
+
464
+ def self.invalid_ttl(raw)
465
+ warn "coord: invalid COORD_LEASE_TTL=#{raw.inspect}; using default #{DEFAULT_LEASE_TTL}s" unless @warned[raw]
466
+ @warned[raw] = true
467
+ DEFAULT_LEASE_TTL
468
+ end
469
+
470
+ def self.expired?(task)
471
+ start = task["start"].to_s
472
+ return false if start.empty?
473
+
474
+ Time.parse(start) + ttl <= Time.now
475
+ rescue ArgumentError
476
+ false
477
+ end
478
+ end
479
+
480
+ # TaskView formats one task with its annotations. An agent reads the task
481
+ # spec through `coord show`, never through raw `task`.
482
+ module TaskView
483
+ FIELDS = %w[uuid description role scope goalid worker status start].freeze
484
+
485
+ def self.lines(task)
486
+ FIELDS.filter_map { |field| "#{field}: #{task[field]}" if task[field] } + notes(task)
487
+ end
488
+
489
+ def self.notes(task)
490
+ (task["annotations"] || []).map { |note| "note #{note["entry"]}: #{note["description"]}" }
491
+ end
492
+ end
493
+
494
+ # Tasks exposes the task operations the flow needs.
495
+ class Tasks
496
+ # The previous owner of a task, split so a steal notice can go to the
497
+ # role's inbox (what agents poll) while still naming the exact worker.
498
+ Holder = Struct.new(:role, :worker)
499
+
500
+ # The goal attribute is `goalid`, not `goal`: Taskwarrior drops the value
501
+ # `role:goal` when a UDA has the same name as the value.
502
+ ATTRIBUTES = { role: "role", scope: "scope", ref: "taskref", project: "project", goal: "goalid" }.freeze
503
+
504
+ def initialize
505
+ @cli = TaskCli.new
506
+ end
507
+
508
+ def available? = @cli.available?
509
+
510
+ def add(title:, **opts)
511
+ @cli.create(add_args(title, opts))
512
+ end
513
+
514
+ def next_for(role, worker = nil)
515
+ pending.select { |task| task["role"] == role && claimable?(task) && actionable?(task) && !held_by?(task, worker) }
516
+ end
517
+
518
+ def mine(role, worker)
519
+ pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] }
520
+ end
521
+
522
+ # Returns the prior holder (nil if there was none) so the caller can warn
523
+ # or notify them. Refuses only an active (non-expired) claim by someone
524
+ # else, unless --force.
525
+ def claim(id, worker, force: false)
526
+ @cli.require!
527
+ task = @cli.task(id)
528
+ holder = task["worker"].to_s
529
+ refuse_steal(id, holder) if active_claim?(task, holder, worker) && !force
530
+ @cli.modify(id, "worker:#{worker}", "start:now") && (Holder.new(task["role"].to_s, holder) unless holder.empty?)
531
+ end
532
+
533
+ def unclaim(id)
534
+ @cli.require!
535
+ @cli.stop(id)
536
+ @cli.modify(id, "worker:")
537
+ end
538
+
539
+ def find(id) = @cli.task(id)
540
+ # A short id of digits only would read as a task number. The filter avoids that.
541
+ def find_short(short) = @cli.task("uuid.startswith:#{short}")
542
+ def for_goal(uuid) = pending.select { |task| task["goalid"] == uuid }
543
+ def done(id) = @cli.done(id)
544
+ def annotate(id, text) = @cli.annotate(id, text)
545
+ def pending = @pending ||= @cli.pending
546
+ def export_to(path) = File.write(path, JSON.pretty_generate(pending))
547
+
548
+ private
549
+
550
+ def actionable?(task) = !Schedule.waiting?(task)
551
+
552
+ def held_by?(task, worker) = !worker.to_s.empty? && task["worker"] == worker
553
+
554
+ def claimable?(task) = task["worker"].to_s.empty? || Lease.expired?(task)
555
+
556
+ def active_claim?(task, holder, worker)
557
+ !holder.empty? && holder != worker && !Lease.expired?(task)
558
+ end
559
+
560
+ def refuse_steal(id, holder)
561
+ raise Error, "task #{id} is already claimed by #{holder} (use --force to steal)"
562
+ end
563
+
564
+ def add_args(title, opts)
565
+ ["add"] + attribute_args(opts) + ["--", title]
566
+ end
567
+
568
+ def attribute_args(opts)
569
+ ATTRIBUTES.filter_map { |key, attr| "#{attr}:#{opts[key]}" if opts[key] }
570
+ end
571
+ end
572
+
573
+ # Locks provides mkdir-based mutual exclusion with TTL metadata.
574
+ class Locks
575
+ def initialize(paths)
576
+ @paths = paths
577
+ end
578
+
579
+ def lock(name, ttl:, worker:)
580
+ acquire!(name, ttl: ttl, worker: worker)
581
+ puts "locked #{name} by #{worker} (ttl #{ttl}s)"
582
+ end
583
+
584
+ def unlock(name)
585
+ release(name)
586
+ puts "unlocked #{name}"
587
+ end
588
+
589
+ def with_lock(name, ttl:, command:, worker:)
590
+ acquire!(name, ttl: ttl, worker: worker)
591
+ run_locked(name, command)
592
+ end
593
+
594
+ # Short critical section (e.g. claiming a task). Retries briefly instead of
595
+ # failing when two workers contend.
596
+ def with_mutex(name, worker:, &block)
597
+ acquire_with_retry(name, worker)
598
+ locked(name, &block)
599
+ end
600
+
601
+ # The lock is released only after it was taken.
602
+ def locked(name)
603
+ yield
604
+ ensure
605
+ release(name)
606
+ end
607
+
608
+ def acquire!(name, ttl:, worker:)
609
+ reclaim_if_stale(name)
610
+ Dir.mkdir(@paths.lock_dir(name))
611
+ write_meta(name, ttl, worker)
612
+ rescue Errno::EEXIST
613
+ raise Error, "lock #{name} busy"
614
+ end
615
+
616
+ def release(name)
617
+ FileUtils.rm_rf(@paths.lock_dir(name))
618
+ end
619
+
620
+ private
621
+
622
+ def acquire_with_retry(name, worker, attempts: 20)
623
+ attempts.times { return true if try_acquire(name, worker) }
624
+ raise Error, "could not lock #{name}"
625
+ end
626
+
627
+ def try_acquire(name, worker)
628
+ acquire!(name, ttl: 30, worker: worker) || true
629
+ rescue Error
630
+ sleep 0.05
631
+ false
632
+ end
633
+
634
+ def run_locked(name, command)
635
+ system(*command)
636
+ ensure
637
+ release(name)
638
+ end
639
+
640
+ def reclaim_if_stale(name)
641
+ return unless Dir.exist?(@paths.lock_dir(name))
642
+ raise Error, "locked by #{holder(name)} (coord unlock #{name})" unless stale?(name)
643
+
644
+ evict(name)
645
+ end
646
+
647
+ # Rename is atomic: of several processes that see the same stale lock, one
648
+ # renames it away. The others get ENOENT and never delete a fresh lock.
649
+ # Metadata lives inside the lock dir, so it moves with the rename.
650
+ def evict(name)
651
+ tomb = "#{@paths.lock_dir(name)}.stale-#{Process.pid}-#{Time.now.to_f}"
652
+ File.rename(@paths.lock_dir(name), tomb)
653
+ FileUtils.rm_rf(tomb)
654
+ rescue Errno::ENOENT
655
+ nil
656
+ end
657
+
658
+ # A lock whose metadata is missing is either a crashed writer or a
659
+ # concurrent writer between mkdir and write_meta. Treat it as stale only
660
+ # after a short grace period, so a racing acquire cannot delete a lock
661
+ # that was just created.
662
+ GRACE_PERIOD = 5
663
+
664
+ def stale?(name)
665
+ meta = read_meta(name)
666
+ return meta["ts"].to_i + meta["ttl"].to_i <= Time.now.to_i unless meta.empty?
667
+
668
+ lock_age(name) > GRACE_PERIOD
669
+ end
670
+
671
+ def lock_age(name)
672
+ Time.now - File.mtime(@paths.lock_dir(name))
673
+ rescue Errno::ENOENT
674
+ Float::INFINITY
675
+ end
676
+
677
+ def holder(name)
678
+ read_meta(name)["worker"] || "unknown"
679
+ end
680
+
681
+ def read_meta(name)
682
+ JSON.parse(File.read(@paths.lock_meta(name)))
683
+ rescue Errno::ENOENT, JSON::ParserError
684
+ {}
685
+ end
686
+
687
+ def write_meta(name, ttl, worker)
688
+ File.write(@paths.lock_meta(name), JSON.generate(worker: worker, ts: Time.now.to_i, ttl: ttl))
689
+ end
690
+ end
691
+
692
+ # Messages writes inbox files and archives them when read.
693
+ class Messages
694
+ def initialize(paths, default_from)
695
+ @paths = paths
696
+ @from = default_from
697
+ end
698
+
699
+ # A message for one worker goes to the inbox of its role. The header
700
+ # names the worker, so the reader knows who the message is for.
701
+ # An FYI message (fyi: true) waits in the inbox and wakes no one. The
702
+ # next run of the role reads it together with the next waking message.
703
+ def send_message(to, from, text, worker: nil, fyi: false)
704
+ path = new_message_path(to, fyi)
705
+ File.write(path, body(to, from, text, worker))
706
+ puts "msg -> #{path}"
707
+ path
708
+ end
709
+
710
+ def inbox(role, peek: false, all: false)
711
+ files = files(role, all: all)
712
+ return puts("no messages for #{role}") if files.empty?
713
+
714
+ files.each { |file| read_message(file, peek) }
715
+ end
716
+
717
+ # Paths of the agent's message files, oldest first. Shared with the
718
+ # `--wait` poller so "is there mail yet" tests exactly what `inbox` reads.
719
+ def files(role, all: false)
720
+ fold_worker_inboxes(role)
721
+ dir = @paths.inbox_dir(role)
722
+ Dir.glob(File.join(dir, all ? "**" : "", "*.md")).sort
723
+ end
724
+
725
+ # The unread messages that justify a model call: every message except FYI.
726
+ def wake_files(role) = files(role).reject { |file| Messages.fyi?(file) }
727
+
728
+ # NOTE: assets/dispatcher and board-watch.rb read the same name mark.
729
+ def self.fyi?(path) = File.basename(path).include?(".fyi.")
730
+
731
+ # Broadcast sends one message to each role in ROLES. Returns the list of
732
+ # [role, path] pairs so the caller can fire hooks for each.
733
+ def broadcast(from, text, roles)
734
+ results = roles.map { |role| [role, send_message(role, from, text)] }
735
+ puts "broadcast -> #{roles.size} role#{"s" if roles.size != 1}"
736
+ results
737
+ end
738
+
739
+ private
740
+
741
+ def new_message_path(to, fyi)
742
+ dir = @paths.inbox_dir(to)
743
+ FileUtils.mkdir_p(dir)
744
+ File.join(dir, "#{stamp}-#{Process.pid}#{".fyi" if fyi}.md")
745
+ end
746
+
747
+ def body(to, from, text, worker)
748
+ worker_line = worker ? "# for: #{worker}\n" : ""
749
+ "# to: #{to}\n#{worker_line}# from: #{from}\n# at: #{Time.now.utc.iso8601}\n\n#{text}\n"
750
+ end
751
+
752
+ # Older versions wrote a message for a worker to inbox/<worker>/. No
753
+ # session reads that folder. Move its unread messages to the role inbox.
754
+ def fold_worker_inboxes(role)
755
+ @paths.worker_inbox_dirs(role).each do |dir|
756
+ Dir.glob(File.join(dir, "*.md")).each { |file| move_to_role(file, role) }
757
+ end
758
+ end
759
+
760
+ def move_to_role(file, role)
761
+ FileUtils.mkdir_p(@paths.inbox_dir(role))
762
+ FileUtils.mv(file, File.join(@paths.inbox_dir(role), File.basename(file)))
763
+ end
764
+
765
+ def stamp
766
+ Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
767
+ end
768
+
769
+ def read_message(file, peek)
770
+ puts "== #{file} =="
771
+ puts File.read(file)
772
+ puts
773
+ archive(file) unless peek || file.include?("/read/")
774
+ end
775
+
776
+ def archive(file)
777
+ dir = File.join(File.dirname(file), "read")
778
+ FileUtils.mkdir_p(dir)
779
+ FileUtils.mv(file, File.join(dir, File.basename(file)))
780
+ end
781
+ end
782
+
783
+ # Hooks runs a per-role shell script when a message is delivered. The hook
784
+ # is a plain user-authored script at .maf/coordination/message-hooks/<role>.sh — coord
785
+ # does not know or care what harness the agent runs in. It starts a fresh
786
+ # one-shot, sends a notification, or does nothing at all. The hook receives
787
+ # COORD_ROLE, COORD_FROM, and COORD_MSG_FILE in its environment, and the
788
+ # message file path as $1. If the hook is absent, coord is silent — it just
789
+ # writes the inbox file, and the agent picks it up on its next `coord inbox`.
790
+ #
791
+ # The hook runs detached in its own process group, so a slow hook (one that
792
+ # starts a whole agent run) never blocks the sender. Its stdout and stderr
793
+ # go to .maf/coordination/message-hooks/<role>.log.
794
+ class Hooks
795
+ def initialize(paths)
796
+ @paths = paths
797
+ end
798
+
799
+ def run(role, from:, message_path:)
800
+ hook = @paths.hook_path(role)
801
+ return false unless File.executable?(hook)
802
+
803
+ Process.detach(spawn_hook(hook, role, from, message_path))
804
+ true
805
+ end
806
+
807
+ private
808
+
809
+ def spawn_hook(hook, role, from, message_path)
810
+ env = { "COORD_ROLE" => role.to_s, "COORD_FROM" => from.to_s, "COORD_MSG_FILE" => message_path }
811
+ log = @paths.hook_log(role)
812
+ Process.spawn(env, hook, message_path, out: [log, "a"], err: [:child, :out], pgroup: true)
813
+ end
814
+ end
815
+
816
+ # Presence records which workers run now, in .maf/coordination/presence/<worker>.json.
817
+ # `maf start` exports COORD_SESSION_PID (the harness pid, kept across exec),
818
+ # so every coord call from that session can record it. The dispatcher writes
819
+ # its own file (mode "dispatch"); a dispatched run (COORD_DISPATCHED) writes
820
+ # none. A worker is live while its pid runs. A session in a container shows
821
+ # as gone, because the host cannot see its pid.
822
+ class Presence
823
+ def initialize(paths)
824
+ @paths = paths
825
+ end
826
+
827
+ def touch(role, worker, env)
828
+ pid = env["COORD_SESSION_PID"].to_i
829
+ return unless pid.positive? && recordable?(role, worker, env)
830
+
831
+ write(worker, "role" => role, "worker" => worker, "mode" => "session", "pid" => pid,
832
+ "started" => Maf::Shared::Processes.started_at(pid))
833
+ end
834
+
835
+ def live?(role) = entries.any? { |entry| entry["role"] == role && self.class.live_entry?(entry) }
836
+
837
+ def report
838
+ return puts("no presence records") if entries.empty?
839
+
840
+ entries.each { |entry| puts line(entry) }
841
+ end
842
+
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
857
+
858
+ private
859
+
860
+ def recordable?(role, worker, env)
861
+ !env["COORD_DISPATCHED"] && ([role, worker] & ["unknown", ""]).empty? && Dir.exist?(@paths.coord_dir)
862
+ end
863
+
864
+ def write(worker, data)
865
+ FileUtils.mkdir_p(@paths.presence_dir)
866
+ path = File.join(@paths.presence_dir, "#{worker}.json")
867
+ File.write(path, JSON.generate(data.merge("seen_at" => Time.now.utc.iso8601)))
868
+ end
869
+
870
+ def entries = Dir.glob(File.join(@paths.presence_dir, "*.json")).sort.filter_map { |path| read(path) }
871
+
872
+ def read(path)
873
+ JSON.parse(File.read(path))
874
+ rescue JSON::ParserError, Errno::ENOENT
875
+ nil
876
+ end
877
+
878
+ def line(entry)
879
+ state = self.class.live_entry?(entry) ? "live" : "gone"
880
+ fields = entry.values_at("worker", "role", "mode") + [state, "pid=#{entry["pid"]}", "seen=#{entry["seen_at"]}"]
881
+ fields.join("\t")
882
+ end
883
+ end
884
+
885
+ # EventLog is a shared, append-only log in .maf/coordination/events.log. Every
886
+ # claim, completion, unclaim, message, and broadcast appends one line so any
887
+ # agent can see the history of coordination without reading individual inbox
888
+ # files. The log is not the source of truth (Taskwarrior is); it is a
889
+ # convenience projection for "what happened while I was working."
890
+ class EventLog
891
+ DETAIL_LIMIT = 80
892
+
893
+ def initialize(paths)
894
+ @paths = paths
895
+ end
896
+
897
+ def append(action, worker, detail)
898
+ FileUtils.mkdir_p(@paths.coord_dir)
899
+ line = [Time.now.utc.iso8601, action, worker, clean(detail)].join("\t")
900
+ File.open(@paths.log_path, "a") { |file| file.puts(line) }
901
+ end
902
+
903
+ def read(lines = nil)
904
+ path = @paths.log_path
905
+ return puts("no events yet") unless File.exist?(path)
906
+
907
+ all = File.readlines(path)
908
+ recent = lines ? all.last(lines) : all
909
+ recent.each { |line| print line }
910
+ end
911
+
912
+ private
913
+
914
+ # Tabs separate fields and newlines separate events, so neither may
915
+ # appear inside a field.
916
+ def clean(detail)
917
+ detail.to_s.gsub(/\s+/, " ").strip.slice(0, DETAIL_LIMIT)
918
+ end
919
+ end
920
+
921
+ # Roster lists every role coord knows about: roles that own a pending task,
922
+ # plus roles declared in the project's .maf/config.json (written by
923
+ # maf add). The manifest sits next to .maf/coordination/, so a worktree
924
+ # that points COORD_DIR at the main checkout still finds it.
925
+ class Roster
926
+ def initialize(paths, tasks)
927
+ @paths = paths
928
+ @tasks = tasks
929
+ end
930
+
931
+ def roles
932
+ (task_roles + manifest_roles).uniq.sort
933
+ end
934
+
935
+ private
936
+
937
+ def task_roles
938
+ @tasks.pending.map { |task| task["role"] }.compact - [Goals::ROLE]
939
+ end
940
+
941
+ def manifest_roles = Manifest.new(@paths.manifest_path).agents.map { |entry| entry["role"] }.compact
942
+ end
943
+
944
+ # Manifest reads .maf/config.json, which `maf add` writes. A project
945
+ # without maf has no manifest; every reader then gets empty defaults.
946
+ class Manifest
947
+ def initialize(path)
948
+ @data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
949
+ rescue JSON::ParserError
950
+ @data = {}
951
+ end
952
+
953
+ def agents = @data.fetch("agents", [])
954
+ def base_branch = @data["base_branch"]
955
+ def verify = @data["verify"].to_s
956
+ def copy_to_worktree = Array(@data["copy_to_worktree"]).map(&:to_s)
957
+ def github = @data.fetch("github", {})
958
+ def reap_minutes = Integer(@data.dig("team", "reap_minutes").to_s, exception: false) || Reaper::DEFAULT_MINUTES
959
+ def harnesses = agents.map { |entry| entry["harness"] }.compact.uniq
960
+
961
+ def harnesses_for(role) = agents.select { |entry| entry["role"] == role }.map { |entry| entry["harness"] }
962
+ end
963
+
964
+ # Audiences names the role groups that `coord broadcast --to` accepts.
965
+ # Leads plan and talk to the user. Workers do the tasks. A notice about
966
+ # ports or test databases is for workers only.
967
+ module Audiences
968
+ PICK = {
969
+ "workers" => ->(role) { !LEADS.include?(role) },
970
+ "leads" => ->(role) { LEADS.include?(role) },
971
+ "all" => ->(_role) { true }
972
+ }.freeze
973
+
974
+ def self.pick(name)
975
+ PICK.fetch(name) { raise Error, "unknown audience '#{name}' (use #{PICK.keys.join(", ")})" }
976
+ end
977
+ end
978
+
979
+ # TokenTotals reads the token usage that the dispatcher adds up per worker
980
+ # in .maf/coordination/usage/<worker>.json. A broken file is skipped.
981
+ class TokenTotals
982
+ def initialize(paths)
983
+ @paths = paths
984
+ end
985
+
986
+ def lines = Dir.glob(File.join(@paths.usage_dir, "*.json")).sort.filter_map { |path| line(path) }
987
+
988
+ private
989
+
990
+ def line(path)
991
+ data = JSON.parse(File.read(path))
992
+ "tokens #{File.basename(path, ".json")}: input=#{data["input_tokens"].to_i} " \
993
+ "cached=#{data["cached_input_tokens"].to_i} output=#{data["output_tokens"].to_i} runs=#{data["runs"].to_i}"
994
+ rescue JSON::ParserError
995
+ nil
996
+ end
997
+ end
998
+
999
+ # GraphAge asks `vault age --json` for the graph age: the commits since the
1000
+ # graph build. Without the vault script or on a failure it gives no line.
1001
+ class GraphAge
1002
+ def initialize(paths)
1003
+ @paths = paths
1004
+ end
1005
+
1006
+ def line
1007
+ output = IO.popen([RbConfig.ruby, @paths.vault_path, "age", "--json"], chdir: root, err: File::NULL, &:read)
1008
+ data = JSON.parse(output)
1009
+ data["commits"] ? "graph age: #{data["commits"]} commits (#{data["state"]})" : "graph age: #{data["state"]}"
1010
+ rescue SystemCallError, JSON::ParserError
1011
+ nil
1012
+ end
1013
+
1014
+ private
1015
+
1016
+ def root = File.dirname(File.dirname(File.expand_path(@paths.coord_dir)))
1017
+ end
1018
+
1019
+ # WorkMemory keeps what the team learned after a task closes. `graphify
1020
+ # save-result` writes one note to graphify-out/memory/. The note cites the graph
1021
+ # nodes of the files that the task branch changed. `graphify reflect` then
1022
+ # rebuilds graphify-out/reflections/LESSONS.md from all notes, and the
1023
+ # dispatcher puts its dead ends and corrections into each task prompt.
1024
+ # Without a graph, it saves nothing.
1025
+ class WorkMemory
1026
+ BRANCH = "maf/memory"
1027
+ ANSWER_LIMIT = 2000
1028
+ NODE_LIMIT = 20
1029
+
1030
+ def self.notes(task)
1031
+ texts = (task["annotations"] || []).map { |note| note["description"].to_s }
1032
+ texts.reject { |text| text.start_with?("LANDED:") }.join("\n")
1033
+ end
1034
+
1035
+ def initialize(root)
1036
+ @root = root
1037
+ end
1038
+
1039
+ # Returns false when there is no graph or graphify fails.
1040
+ def save(task, outcome, answer, correction = nil)
1041
+ return false unless File.exist?(graph)
1042
+ return false unless write_note(task, outcome, answer.to_s[0, ANSWER_LIMIT], correction)
1043
+
1044
+ commit("memory: #{outcome}: #{task["description"]}")
1045
+ reflect
1046
+ end
1047
+
1048
+ private
1049
+
1050
+ def write_note(task, outcome, answer, correction)
1051
+ graphify("save-result", "--question", question(task, outcome, answer), "--outcome", outcome, "--answer", answer,
1052
+ "--memory-dir", folder("memory"), *correction_args(correction), *nodes_args(task))
1053
+ end
1054
+
1055
+ # graphify-out/memory/ can be a worktree of the branch maf/memory (ADR 0006).
1056
+ # Each note is one commit there. A commit fails while another one runs. The
1057
+ # note then stays, and the next commit takes it.
1058
+ def commit(message)
1059
+ dir = folder("memory")
1060
+ Git.run(dir, "add", "-A") && Git.run(dir, "commit", "-q", "-m", message) if File.exist?(File.join(dir, ".git"))
1061
+ end
1062
+
1063
+ def reflect
1064
+ graphify("reflect", "--graph", graph, "--memory-dir", folder("memory"), "--out", folder("reflections/LESSONS.md"))
1065
+ end
1066
+
1067
+ # LESSONS.md lists a dead end by its question only. The question keeps the
1068
+ # reason, so the reason reaches the prompts.
1069
+ def question(task, outcome, answer)
1070
+ outcome == "dead_end" ? "#{task["description"]}: #{answer}" : task["description"].to_s
1071
+ end
1072
+
1073
+ def correction_args(text) = text ? ["--correction", text] : []
1074
+ def graph = folder("graph.json")
1075
+ def folder(name) = File.join(@root, "graphify-out", name)
1076
+
1077
+ # graphify takes paths from the arguments, never from GRAPHIFY_OUT.
1078
+ def graphify(*args)
1079
+ system({ "GRAPHIFY_OUT" => nil }, "graphify", *args, chdir: @root, out: File::NULL, err: File::NULL)
1080
+ end
1081
+
1082
+ # --nodes takes the rest of the arguments, so it comes last.
1083
+ def nodes_args(task)
1084
+ ids = node_ids(changed_files(task)).first(NODE_LIMIT)
1085
+ ids.empty? ? [] : ["--nodes", *ids]
1086
+ end
1087
+
1088
+ def changed_files(task)
1089
+ return [] if task["goalid"].to_s.empty?
1090
+
1091
+ range = "#{Goals.branch(task["goalid"])}...task/#{Goals.short(task["uuid"])}"
1092
+ Git.out(@root, "diff", "--name-only", range).lines(chomp: true)
1093
+ end
1094
+
1095
+ def node_ids(files)
1096
+ nodes = files.empty? ? [] : JSON.parse(File.read(graph))["nodes"] || []
1097
+ files.filter_map { |file| nodes.find { |node| file_node?(node, file) }&.fetch("id", nil) }
1098
+ rescue JSON::ParserError
1099
+ []
1100
+ end
1101
+
1102
+ # A file node has the file name as label. graphify may store the source path
1103
+ # relative to the project or absolute.
1104
+ def file_node?(node, file)
1105
+ node["label"] == File.basename(file) && [file, File.join(@root, file)].include?(node["source_file"])
1106
+ end
1107
+ end
1108
+
1109
+ # Board renders status summaries and the Obsidian kanban file.
1110
+ class Board
1111
+ SECTIONS = {
1112
+ "Backlog" => ->(task) { !Schedule.waiting?(task) && !task["start"] },
1113
+ "In progress" => ->(task) { !Schedule.waiting?(task) && task["start"] },
1114
+ "Waiting" => ->(task) { Schedule.waiting?(task) }
1115
+ }.freeze
1116
+
1117
+ def initialize(tasks, paths)
1118
+ @tasks = tasks
1119
+ @paths = paths
1120
+ end
1121
+
1122
+ def status
1123
+ rows = grouped
1124
+ rows.empty? ? puts("no tasks") : rows.each { |role, list| puts summary_line(role, list) }
1125
+ TokenTotals.new(@paths).lines.each { |line| puts line }
1126
+ GraphAge.new(@paths).line&.then { |line| puts line }
1127
+ end
1128
+
1129
+ def next_tasks(role, worker = nil)
1130
+ list = @tasks.next_for(role, worker)
1131
+ return puts("no unclaimed tasks for #{role}") if list.empty?
1132
+
1133
+ list.each { |task| puts task_line(task) }
1134
+ end
1135
+
1136
+ def mine_tasks(role, worker)
1137
+ list = @tasks.mine(role, worker)
1138
+ return puts("no in-progress tasks for #{worker}") if list.empty?
1139
+
1140
+ list.each { |task| puts task_line(task) }
1141
+ end
1142
+
1143
+ def board
1144
+ File.write(@paths.board_path, board_body)
1145
+ puts "board -> #{@paths.board_path}"
1146
+ end
1147
+
1148
+ private
1149
+
1150
+ def summary_line(role, list)
1151
+ counts = list.group_by { |task| bucket(task) }.transform_values(&:size)
1152
+ "#{role}: #{counts.map { |key, value| "#{key}=#{value}" }.join(" ")}"
1153
+ end
1154
+
1155
+ def task_line(task)
1156
+ [task["uuid"], task["description"], task["scope"], task["worker"]].compact.join("\t")
1157
+ end
1158
+
1159
+ def grouped
1160
+ @tasks.pending.group_by { |task| task["role"] || "unassigned" }.sort.to_h
1161
+ end
1162
+
1163
+ def bucket(task)
1164
+ return "waiting" if Schedule.waiting?(task)
1165
+
1166
+ task["start"] ? "in-progress" : "backlog"
1167
+ end
1168
+
1169
+ # The `kanban-plugin: basic` frontmatter is what the Obsidian Kanban
1170
+ # community plugin (mgmeyers/obsidian-kanban) checks for to render this
1171
+ # file as a board instead of a plain markdown note. Without it, `##`
1172
+ # headings and `- [ ]` items still read fine as an outline, but Obsidian
1173
+ # never offers the drag-and-drop board view for the file.
1174
+ def board_body
1175
+ frontmatter = "---\nkanban-plugin: basic\n---\n\n"
1176
+ frontmatter + SECTIONS.map { |title, pick| section(title, @tasks.pending.select(&pick)) }.join
1177
+ end
1178
+
1179
+ def section(title, list)
1180
+ "## #{title}\n\n#{list.map { |task| card(task) }.join("\n")}\n\n"
1181
+ end
1182
+
1183
+ def card(task)
1184
+ scope = task["scope"].to_s
1185
+ scope_part = scope.empty? ? "" : " `#{scope}`"
1186
+ "- [ ] #{owner_label(task)} \u2014 #{task["description"]}#{scope_part}"
1187
+ end
1188
+
1189
+ def owner_label(task)
1190
+ worker = task["worker"].to_s
1191
+ worker.empty? ? (task["role"] || "unassigned") : "#{task["role"]}/#{worker}"
1192
+ end
1193
+ end
1194
+
1195
+ # Git wraps the few git calls that goals and task branches need.
1196
+ module Git
1197
+ def self.run(dir, *args) = system("git", "-C", dir, *args, out: File::NULL, err: File::NULL)
1198
+ def self.branch?(dir, name) = run(dir, "show-ref", "--verify", "--quiet", "refs/heads/#{name}")
1199
+ def self.clean?(dir) = `git -C #{Shellwords.escape(dir)} status --porcelain --untracked-files=no`.strip.empty?
1200
+ def self.out(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read).strip
1201
+ def self.ancestor?(dir, older, newer) = run(dir, "merge-base", "--is-ancestor", older, newer)
1202
+ def self.own_commits?(dir, base, branch) = out(dir, "rev-list", "--count", "#{base}..#{branch}").to_i.positive?
1203
+ def self.branches(dir, pattern)
1204
+ out(dir, "branch", "--list", pattern, "--format=%(refname:short)").lines(chomp: true)
1205
+ end
1206
+
1207
+ # With an origin, the remote base branch is the truth: a merged pull
1208
+ # request lands there first. Without an origin, the local branch counts.
1209
+ def self.base_ref(dir, base) = run(dir, "fetch", "-q", "origin", base) ? "origin/#{base}" : base
1210
+
1211
+ # Maps each checked-out branch to the worktree that holds it.
1212
+ def self.checked_out(dir)
1213
+ blocks = out(dir, "worktree", "list", "--porcelain").split("\n\n")
1214
+ blocks.filter_map do |block|
1215
+ branch = block[%r{^branch refs/heads/(.+)$}, 1]
1216
+ [branch, block[/^worktree (.+)$/, 1]] if branch
1217
+ end.to_h
1218
+ end
1219
+
1220
+ # The base branch is where each goal starts: the manifest's base_branch,
1221
+ # else the branch that origin/HEAD names, else main.
1222
+ def self.base_branch(dir, manifest)
1223
+ configured = manifest.base_branch
1224
+ return configured unless configured.to_s.empty?
1225
+
1226
+ remote = `git -C #{Shellwords.escape(dir)} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
1227
+ remote.delete_prefix("origin/").then { |name| name.empty? ? "main" : name }
1228
+ end
1229
+ end
1230
+
1231
+ # Goals tracks user-visible outcomes above tasks. A goal is a task with
1232
+ # role "goal". Its tasks store the goal's uuid in the `goal` attribute.
1233
+ # Each goal has its own branch goal/<short id> from the base branch, so
1234
+ # one goal never builds on top of another goal.
1235
+ class Goals
1236
+ ROLE = "goal"
1237
+
1238
+ def self.short(uuid) = uuid.to_s[0, 8]
1239
+ def self.branch(uuid) = "goal/#{short(uuid)}"
1240
+ def self.slug(uuid) = "goal-#{short(uuid)}"
1241
+
1242
+ def initialize(tasks)
1243
+ @tasks = tasks
1244
+ end
1245
+
1246
+ def find!(id)
1247
+ goal = @tasks.find(id)
1248
+ raise Error, "no goal #{id}" unless goal["role"] == ROLE
1249
+
1250
+ goal
1251
+ end
1252
+
1253
+ def list
1254
+ goals = @tasks.pending.select { |task| task["role"] == ROLE }
1255
+ return puts("no open goals") if goals.empty?
1256
+
1257
+ goals.each { |goal| puts goal_line(goal) }
1258
+ end
1259
+
1260
+ def show(id)
1261
+ goal = find!(id)
1262
+ puts goal_line(goal)
1263
+ list = @tasks.for_goal(goal["uuid"])
1264
+ list.empty? ? puts(" no open tasks") : list.each { |task| puts task_line(task) }
1265
+ end
1266
+
1267
+ def done(id)
1268
+ goal = find!(id)
1269
+ refuse_open!(goal)
1270
+ @tasks.done(goal["uuid"])
1271
+ goal
1272
+ end
1273
+
1274
+ def refuse_open!(goal)
1275
+ open = @tasks.for_goal(goal["uuid"]).size
1276
+ raise Error, "goal #{goal["uuid"]} has #{open} open task#{"s" if open != 1}" if open.positive?
1277
+ end
1278
+
1279
+ private
1280
+
1281
+ def goal_line(goal)
1282
+ open = @tasks.for_goal(goal["uuid"]).size
1283
+ [goal["uuid"], goal["description"], self.class.branch(goal["uuid"]), "open=#{open}"].join("\t")
1284
+ end
1285
+
1286
+ def task_line(task)
1287
+ state = task["start"] ? "in-progress" : "backlog"
1288
+ " " + [task["uuid"], task["role"], state, task["worker"], task["description"]].compact.join("\t")
1289
+ end
1290
+ end
1291
+
1292
+ # TaskBranch checks out one branch per task in the worker's worktree. The
1293
+ # branch starts from the task's goal branch, so each task builds on its own
1294
+ # goal only. A task without a goal starts from the base branch.
1295
+ class TaskBranch
1296
+ def initialize(dir)
1297
+ @dir = dir
1298
+ end
1299
+
1300
+ def start(uuid, base)
1301
+ raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
1302
+
1303
+ branch = "task/#{Goals.short(uuid)}"
1304
+ raise Error, "git checkout #{branch} failed" unless checkout(branch, base)
1305
+
1306
+ puts "on branch #{branch} (from #{base})"
1307
+ end
1308
+
1309
+ private
1310
+
1311
+ def checkout(branch, base)
1312
+ return Git.run(@dir, "checkout", "-q", branch) if Git.branch?(@dir, branch)
1313
+
1314
+ Git.run(@dir, "checkout", "-q", "-b", branch, base)
1315
+ end
1316
+ end
1317
+
1318
+ # TaskSync checks that a task branch contains the head of its goal branch.
1319
+ # Task tests on a branch without the merged sibling tasks miss semantic
1320
+ # conflicts, so `coord done` refuses a stale branch. A task branch without
1321
+ # own commits (a review task) has nothing to integrate and passes.
1322
+ class TaskSync
1323
+ def initialize(dir)
1324
+ @dir = dir
1325
+ end
1326
+
1327
+ def stale(task)
1328
+ goal = task["goalid"]
1329
+ return nil if goal.to_s.empty?
1330
+
1331
+ pair = [Goals.branch(goal), "task/#{Goals.short(task["uuid"])}"]
1332
+ pair if pair.all? { |name| Git.branch?(@dir, name) } && behind?(*pair)
1333
+ end
1334
+
1335
+ private
1336
+
1337
+ def behind?(goal, branch)
1338
+ own = `git -C #{Shellwords.escape(@dir)} rev-list --count #{goal}..#{branch} 2>/dev/null`.to_i
1339
+ own.positive? && !Git.run(@dir, "merge-base", "--is-ancestor", goal, branch)
1340
+ end
1341
+ end
1342
+
1343
+ # SquashMessage builds the commit message of one landed task. The trailers
1344
+ # keep the trace to the task after the squash.
1345
+ module SquashMessage
1346
+ SUBJECT_LIMIT = 72
1347
+
1348
+ def self.for(task, subject)
1349
+ head = (subject || task["description"]).to_s.strip.slice(0, SUBJECT_LIMIT)
1350
+ trailers = { "Task" => task["uuid"], "Goal" => task["goalid"], "Worker" => task["worker"],
1351
+ "Tests" => tests(task) }
1352
+ "#{head}\n\n#{trailers.filter_map { |key, value| "#{key}: #{value}" unless value.to_s.empty? }.join("\n")}\n"
1353
+ end
1354
+
1355
+ # The worker report holds "TESTS: <result>. NOTES: ...". The last report counts.
1356
+ def self.tests(task)
1357
+ notes = (task["annotations"] || []).map { |note| note["description"].to_s }
1358
+ notes.reverse.find { |text| text.include?("TESTS:") }.to_s[/TESTS:\s*(.+?)(?:\s+NOTES:|\z)/m, 1]
1359
+ end
1360
+ end
1361
+
1362
+ # Land squashes the branch of one done task into its goal branch as one
1363
+ # commit. The task branch is then deleted. A worker worktree that still has
1364
+ # the task branch checked out moves back to its worker branch first.
1365
+ class Land
1366
+ def initialize(root, task)
1367
+ @root, @task = root, task
1368
+ end
1369
+
1370
+ def run(subject)
1371
+ check!
1372
+ commit = squash(subject)
1373
+ release_branch
1374
+ commit
1375
+ end
1376
+
1377
+ def goal_branch = Goals.branch(@task["goalid"])
1378
+ def branch = "task/#{Goals.short(@task["uuid"])}"
1379
+
1380
+ private
1381
+
1382
+ def goal_dir = Worktree.dir_for(@root, Goals.slug(@task["goalid"]))
1383
+
1384
+ def check!
1385
+ raise Error, "task #{@task["uuid"]} has no goal. coord land needs a goal branch." if @task["goalid"].to_s.empty?
1386
+ raise Error, "task #{@task["uuid"]} is not done. Run coord done first." unless @task["status"] == "completed"
1387
+ raise Error, "branch #{branch} does not exist" unless Git.branch?(@root, branch)
1388
+ raise Error, "uncommitted changes in #{goal_dir}. Commit or stash them first." unless Git.clean?(goal_dir)
1389
+ end
1390
+
1391
+ # A branch without own commits (a review task) lands nothing.
1392
+ def squash(subject)
1393
+ return nil unless Git.own_commits?(@root, goal_branch, branch)
1394
+ raise Error, STALE % { branch: branch, goal: goal_branch } unless Git.ancestor?(@root, goal_branch, branch)
1395
+
1396
+ merge_squash
1397
+ commit(SquashMessage.for(@task, subject))
1398
+ end
1399
+
1400
+ STALE = "branch %<branch>s lacks the head of %<goal>s. Open a fix task: merge %<goal>s into %<branch>s."
1401
+
1402
+ def merge_squash
1403
+ return if Git.run(goal_dir, "merge", "--squash", "-q", branch)
1404
+
1405
+ Git.run(goal_dir, "reset", "-q", "--merge")
1406
+ raise Error, "the squash of #{branch} into #{goal_branch} conflicts. Open a fix task."
1407
+ end
1408
+
1409
+ # 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")
1412
+
1413
+ args = ["git", "-C", goal_dir, "commit", "-q", "--no-verify", "-F", "-"]
1414
+ IO.popen(args, "w") { |io| io.write(message) }
1415
+ raise Error, "git commit on #{goal_branch} failed" unless $?.success?
1416
+
1417
+ Git.out(goal_dir, "rev-parse", "--short", "HEAD")
1418
+ end
1419
+
1420
+ def release_branch
1421
+ holder = Git.checked_out(@root)[branch]
1422
+ return warn("coord: kept #{branch}. #{holder} has uncommitted changes.") if holder && !leave(holder)
1423
+
1424
+ Git.run(@root, "branch", "-D", branch)
1425
+ end
1426
+
1427
+ # A worker worktree goes back to worker/<slug>. Without that branch, it detaches at the goal head.
1428
+ def leave(dir)
1429
+ return false unless Git.clean?(dir)
1430
+
1431
+ worker = "worker/#{File.basename(dir)}"
1432
+ target = Git.branch?(@root, worker) ? [worker] : ["--detach", goal_branch]
1433
+ Git.run(dir, "switch", "-q", *target)
1434
+ end
1435
+ end
1436
+
1437
+ # GoalSync merges the base branch into a goal branch, so the pull request
1438
+ # of the goal has no conflict and the merge suite tests the merged result.
1439
+ class GoalSync
1440
+ def initialize(root, goal_uuid, base)
1441
+ @root, @base = root, base
1442
+ @branch = Goals.branch(goal_uuid)
1443
+ @dir = Worktree.dir_for(root, Goals.slug(goal_uuid))
1444
+ end
1445
+
1446
+ # A missing base ref has no head to lack.
1447
+ def behind? = Git.run(@root, "rev-parse", "-q", "--verify", ref) && !Git.ancestor?(@root, ref, @branch)
1448
+ def ref = @ref ||= Git.base_ref(@root, @base)
1449
+
1450
+ def run
1451
+ return puts("#{@branch} already contains #{ref}") unless behind?
1452
+ raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
1453
+
1454
+ merge
1455
+ puts "merged #{ref} into #{@branch}"
1456
+ end
1457
+
1458
+ private
1459
+
1460
+ def merge
1461
+ return if Git.run(@dir, "merge", "-q", "--no-edit", ref)
1462
+
1463
+ Git.run(@dir, "merge", "--abort")
1464
+ raise Error, "the merge of #{ref} into #{@branch} conflicts. Open a fix task to merge #{ref} into #{@branch}."
1465
+ end
1466
+ end
1467
+
1468
+ # Sweep finds flow branches and worktrees that finished their work. It
1469
+ # deletes only what git or the task board proves is merged or landed.
1470
+ # Everything else stays and gets a reason.
1471
+ class Sweep
1472
+ Item = Struct.new(:label, :action, :reason)
1473
+
1474
+ def initialize(root, tasks, base)
1475
+ @root, @tasks, @base = root, tasks, base
1476
+ end
1477
+
1478
+ def items = task_items + goal_items + worker_items
1479
+
1480
+ private
1481
+
1482
+ def ref = @ref ||= Git.base_ref(@root, @base)
1483
+ def held = @held ||= Git.checked_out(@root)
1484
+
1485
+ def task_items
1486
+ Git.branches(@root, "task/*").map { |name| item("branch #{name}", task_reason(name)) { delete(name) } }
1487
+ end
1488
+
1489
+ def goal_items
1490
+ Git.branches(@root, "goal/*").map do |name|
1491
+ item("goal #{name} and its worktree", goal_reason(name)) { drop_goal(name) }
1492
+ end
1493
+ end
1494
+
1495
+ def worker_items
1496
+ Git.branches(@root, "worker/*").map { |name| item("branch #{name}", worker_reason(name)) { delete(name) } }
1497
+ end
1498
+
1499
+ # A nil reason means: safe to delete.
1500
+ def item(label, reason, &action) = Item.new(label, reason ? nil : action, reason)
1501
+
1502
+ def task_reason(name)
1503
+ return "checked out in #{held[name]}" if held[name]
1504
+ return nil if Git.ancestor?(@root, name, ref)
1505
+
1506
+ task = @tasks.find_short(name.delete_prefix("task/"))
1507
+ return "task is not done" unless task["status"] == "completed"
1508
+
1509
+ landed?(task) ? nil : "task is done but not landed (coord land)"
1510
+ end
1511
+
1512
+ def landed?(task) = (task["annotations"] || []).any? { |note| note["description"].to_s.start_with?("LANDED:") }
1513
+
1514
+ def goal_reason(name)
1515
+ goal = @tasks.find_short(name.delete_prefix("goal/"))
1516
+ return "goal is not done" unless goal["status"] == "completed"
1517
+
1518
+ Git.ancestor?(@root, name, ref) ? nil : "not merged into #{ref}"
1519
+ end
1520
+
1521
+ def worker_reason(name)
1522
+ return "checked out in #{held[name]}" if held[name]
1523
+
1524
+ Git.ancestor?(@root, name, ref) ? nil : "has commits that #{ref} lacks"
1525
+ end
1526
+
1527
+ def delete(name) = Git.run(@root, "branch", "-D", name)
1528
+
1529
+ # Untracked runtime copies would block `worktree remove`. Git.clean? has
1530
+ # already checked the tracked files, so --force removes only those copies.
1531
+ def drop_goal(name)
1532
+ dir = held[name]
1533
+ return false if dir && !(Git.clean?(dir) && Git.run(@root, "worktree", "remove", "--force", dir))
1534
+
1535
+ delete(name)
1536
+ end
1537
+ end
1538
+
1539
+ # Await arms the stop hook of an interactive session. The agent ends its
1540
+ # turn, and the hook (harness-hooks/next-task.rb) waits for work outside the
1541
+ # model. A wait in a tool call costs one model call per return; this wait
1542
+ # costs none. NOTE: next-task.rb reads the same file.
1543
+ class Await
1544
+ DEFAULT_SECONDS = 3000
1545
+
1546
+ def initialize(paths, worker)
1547
+ @path = File.join(paths.locks_dir, "await-#{worker}.json")
1548
+ end
1549
+
1550
+ def arm(role, seconds)
1551
+ FileUtils.mkdir_p(File.dirname(@path))
1552
+ File.write(@path, JSON.generate("role" => role, "until" => Time.now.to_i + seconds))
1553
+ end
1554
+ end
1555
+
1556
+ # InboxWaiter allows one `coord inbox --wait` for each role. Two waiters on
1557
+ # one inbox split the messages between two agents. The pid file of a waiter
1558
+ # that exited without cleanup counts as free.
1559
+ class InboxWaiter
1560
+ def initialize(paths, role)
1561
+ @role = role
1562
+ @path = File.join(paths.locks_dir, "inbox-wait-#{role}.pid")
1563
+ end
1564
+
1565
+ def hold
1566
+ refuse_other!
1567
+ write_pid
1568
+ yield
1569
+ ensure
1570
+ File.delete(@path) if own?
1571
+ end
1572
+
1573
+ def write_pid
1574
+ FileUtils.mkdir_p(File.dirname(@path))
1575
+ File.write(@path, Process.pid.to_s)
1576
+ end
1577
+
1578
+ def own? = File.exist?(@path) && File.read(@path).to_i == Process.pid
1579
+
1580
+ private
1581
+
1582
+ def refuse_other!
1583
+ pid = File.exist?(@path) ? File.read(@path).to_i : 0
1584
+ return unless pid.positive? && pid != Process.pid && Maf::Shared::Processes.alive?(pid)
1585
+
1586
+ raise Error, "another coord inbox --wait for #{@role} runs (pid #{pid}). Read the messages in that session."
1587
+ end
1588
+ end
1589
+
1590
+ # Reaper finds claims of workers that were not seen for a while. A worker
1591
+ # counts as seen at its claim, at each change of the task, at each event
1592
+ # it logs, and at each coord call of its session (presence).
1593
+ # A live dispatcher is never stale: a run can take hours on a local model,
1594
+ # and the dispatcher resumes the claims of its worker by itself.
1595
+ class Reaper
1596
+ DEFAULT_MINUTES = 90
1597
+
1598
+ def initialize(tasks, paths, minutes)
1599
+ @tasks, @paths, @limit = tasks, paths, minutes * 60
1600
+ end
1601
+
1602
+ def stale = @tasks.pending.select { |task| claimed?(task) && !dispatched?(task) && quiet?(task) }
1603
+ def quiet?(task) = Time.now - last_seen(task) >= @limit
1604
+
1605
+ def last_seen(task)
1606
+ times = [task["start"], task["modified"], presence(task["worker"]), last_event(task["worker"])]
1607
+ times.filter_map { |value| parse(value) }.max || Time.now
1608
+ end
1609
+
1610
+ private
1611
+
1612
+ def claimed?(task) = task["start"] && !task["worker"].to_s.empty?
1613
+
1614
+ def parse(value)
1615
+ value && Time.parse(value.to_s)
1616
+ rescue ArgumentError
1617
+ nil
1618
+ end
1619
+
1620
+ def dispatched?(task)
1621
+ entry = entry(task["worker"])
1622
+ entry["mode"] == "dispatch" && Presence.live_entry?(entry)
1623
+ end
1624
+
1625
+ def presence(worker) = entry(worker)["seen_at"]
1626
+
1627
+ def entry(worker)
1628
+ JSON.parse(File.read(File.join(@paths.presence_dir, "#{worker}.json")))
1629
+ rescue Errno::ENOENT, JSON::ParserError
1630
+ {}
1631
+ end
1632
+
1633
+ def last_event(worker) = events.reverse.find { |line| line.split("\t")[2] == worker }&.split("\t")&.first
1634
+ def events = @events ||= File.exist?(@paths.log_path) ? File.readlines(@paths.log_path).last(2000) : []
1635
+ end
1636
+
1637
+ # GitHub runs `gh` as the bot account of the github section in
1638
+ # .maf/config.json. The bot opens the pull request, so the reviewer can
1639
+ # approve it or request changes. Your own `gh` login stays active.
1640
+ class GitHub
1641
+ def initialize(root, settings)
1642
+ @root, @settings = root, settings
1643
+ end
1644
+
1645
+ def bot = @settings["bot_user"].to_s
1646
+ def reviewer = @settings["reviewer"].to_s
1647
+ def configured? = !bot.empty? && !reviewer.empty?
1648
+
1649
+ def repo
1650
+ @repo ||= @settings["repo"] || origin_repo ||
1651
+ raise(Error, "no GitHub repository. Set github.repo in .maf/config.json.")
1652
+ end
1653
+
1654
+ # ssh_host is a Host alias of ~/.ssh/config with the key of the bot.
1655
+ def remote = @settings["ssh_host"] ? "git@#{@settings["ssh_host"]}:#{repo}.git" : "origin"
1656
+
1657
+ def push(branch)
1658
+ return if Git.run(@root, "push", "-q", remote, "#{branch}:#{branch}")
1659
+
1660
+ raise Error, "git push of #{branch} to #{remote} failed"
1661
+ end
1662
+
1663
+ def create_pr(head:, base:, title:, body:)
1664
+ args = ["--head", head, "--base", base, "--title", title, "--reviewer", reviewer, "--body-file", "-"]
1665
+ gh("pr", "create", "--repo", repo, *args, input: body).lines.last.to_s.strip
1666
+ end
1667
+
1668
+ def request_review(url) = gh("pr", "edit", url, "--add-reviewer", reviewer)
1669
+ def comment(url, text) = gh("pr", "comment", url, "--body-file", "-", input: text)
1670
+ def view(url) = JSON.parse(gh("pr", "view", url, "--json", "state,reviews,comments"))
1671
+ def inline_comments(url) = JSON.parse(gh("api", "repos/#{repo}/pulls/#{url[%r{/pull/(\d+)}, 1]}/comments"))
1672
+
1673
+ private
1674
+
1675
+ # The origin URL can use an ssh Host alias such as github.com-maf-bot.
1676
+ ORIGIN = %r{github\.com[^:/]*[:/]([^/]+/[^/]+?)(?:\.git)?\z}
1677
+
1678
+ def origin_repo = Git.out(@root, "remote", "get-url", "origin")[ORIGIN, 1]
1679
+
1680
+ def gh(*args, input: nil)
1681
+ out, err, status = Open3.capture3(env, "gh", *args, stdin_data: input.to_s)
1682
+ raise Error, "gh #{args.first(2).join(" ")} failed: #{err.strip}" unless status.success?
1683
+
1684
+ out
1685
+ rescue Errno::ENOENT
1686
+ raise Error, "gh is not installed. See https://cli.github.com"
1687
+ end
1688
+
1689
+ def env = @env ||= { "GH_TOKEN" => token }
1690
+
1691
+ def token
1692
+ out, _err, status = Open3.capture3("gh", "auth", "token", "--user", bot)
1693
+ raise Error, "gh has no login for #{bot}. Run: gh auth login (as #{bot})" unless status.success?
1694
+
1695
+ out.strip
1696
+ end
1697
+ end
1698
+
1699
+ # ReviewState keeps the pull request of each goal and the review items that
1700
+ # coord already forwarded, in .maf/coordination/reviews.json.
1701
+ class ReviewState
1702
+ def initialize(paths)
1703
+ @path = File.join(paths.coord_dir, "reviews.json")
1704
+ @locks = Locks.new(paths)
1705
+ end
1706
+
1707
+ def all = File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
1708
+ def get(uuid) = all[uuid]
1709
+ def put(uuid, entry) = change { |data| data.merge(uuid => entry) }
1710
+ def drop(uuid) = change { |data| data.reject { |key, _| key == uuid } }
1711
+
1712
+ private
1713
+
1714
+ def change
1715
+ @locks.with_mutex("reviews", worker: "coord") { File.write(@path, JSON.pretty_generate(yield(all))) }
1716
+ end
1717
+ end
1718
+
1719
+ # ReviewFeed reads one pull request and lists the review items that coord
1720
+ # has not forwarded yet. It ignores every author except the reviewer.
1721
+ class ReviewFeed
1722
+ def initialize(github, entry)
1723
+ @github, @entry = github, entry
1724
+ @seen = entry.fetch("seen", [])
1725
+ end
1726
+
1727
+ def state = view["state"]
1728
+ def items = @items ||= (review_items + inline_items + comment_items).reject { |id, _| @seen.include?(id) }
1729
+ def seen_after = @seen + items.map(&:first)
1730
+ def changes_requested? = new_reviews.any? { |review| review["state"] == "CHANGES_REQUESTED" }
1731
+
1732
+ private
1733
+
1734
+ def view = @view ||= @github.view(@entry["url"])
1735
+ def by_reviewer?(item, key = "author") = item.dig(key, "login") == @github.reviewer
1736
+ def new_reviews = (view["reviews"] || []).select { |r| by_reviewer?(r) && !@seen.include?("review-#{r["id"]}") }
1737
+ def review_items = new_reviews.map { |r| ["review-#{r["id"]}", "#{r["state"]}: #{r["body"]}".strip] }
1738
+ def comment_items
1739
+ (view["comments"] || []).select { |c| by_reviewer?(c) }.map { |c| ["comment-#{c["id"]}", c["body"]] }
1740
+ end
1741
+
1742
+ # Inline comments come with a review, so only a new review needs the extra call.
1743
+ def inline_items
1744
+ return [] if new_reviews.empty?
1745
+
1746
+ list = @github.inline_comments(@entry["url"]).select { |c| by_reviewer?(c, "user") }
1747
+ list.map { |c| ["inline-#{c["id"]}", "#{c["path"]}:#{c["line"] || c["original_line"]}: #{c["body"]}"] }
1748
+ end
1749
+ end
1750
+
1751
+ # PrBody describes a goal pull request: one line for each landed task.
1752
+ module PrBody
1753
+ def self.for(root, goal, base_ref)
1754
+ range = "#{base_ref}..#{Goals.branch(goal["uuid"])}"
1755
+ commits = Git.out(root, "log", "--first-parent", "--reverse", "--format=- %h %s", range)
1756
+ "Goal: #{goal["description"]}\nGoal id: #{goal["uuid"]}\n\nCommits (one for each task):\n#{commits}\n\n" \
1757
+ "Review with Approve or Request changes. Coord sends your review to the architect.\n" \
1758
+ "Merge with a merge commit. Do not squash.\n"
1759
+ end
1760
+ end
1761
+
1762
+ # Notify shows a desktop notification on macOS. MAF_NOTIFY=0 turns it off.
1763
+ module Notify
1764
+ def self.user(title, text)
1765
+ return if ENV["MAF_NOTIFY"] == "0" || !system("command -v osascript >/dev/null 2>&1")
1766
+
1767
+ clean = ->(value) { value.to_s.delete("\"\\") }
1768
+ system("osascript", "-e", %(display notification "#{clean.(text)}" with title "#{clean.(title)}"),
1769
+ out: File::NULL, err: File::NULL)
1770
+ end
1771
+ end
1772
+
1773
+ # Slots gives each worktree a stable number. Parallel worktrees use the
1774
+ # number to derive unique test databases and server ports. The main
1775
+ # worktree has no .maf/env.sh and counts as slot 0.
1776
+ class Slots
1777
+ def initialize(coord_dir)
1778
+ @coord_dir = coord_dir
1779
+ @path = File.join(coord_dir, "slots.json")
1780
+ @paths = Paths.new(coord_dir)
1781
+ end
1782
+
1783
+ def assign(slug)
1784
+ FileUtils.mkdir_p(@paths.locks_dir)
1785
+ Locks.new(@paths).with_mutex("slots", worker: "coord") { write(slot_for(live, slug))[slug] }
1786
+ end
1787
+
1788
+ private
1789
+
1790
+ def slot_for(slots, slug)
1791
+ slots[slug] ||= (slots.values.max || 0) + 1
1792
+ slots
1793
+ end
1794
+
1795
+ def read = File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
1796
+
1797
+ # A worktree that is gone frees its slot, so a retired worker does not
1798
+ # push every later worker's port higher. Retire and a manual
1799
+ # `git worktree remove` both free the slot on the next assign.
1800
+ def live = read.select { |slug, _slot| Dir.exist?(worktree_dir(slug)) }
1801
+
1802
+ def worktree_dir(slug) = Worktree.dir_for(project_root, slug)
1803
+ def project_root = File.dirname(File.dirname(File.expand_path(@coord_dir)))
1804
+
1805
+ def write(slots)
1806
+ File.write(@path, JSON.pretty_generate(slots))
1807
+ slots
1808
+ end
1809
+ end
1810
+
1811
+ # WorktreeEnv writes a worktree's .maf/env.sh: the shared board paths, the
1812
+ # worktree's slot, and the output of the optional project hook
1813
+ # .maf/coordination/worktree-env.rb. The hook gets COORD_SLOT and COORD_WORKTREE
1814
+ # and prints `export NAME=VALUE` lines (see assets/worktree-env.example.rb).
1815
+ class WorktreeEnv
1816
+ FILE = ".maf/env.sh"
1817
+ HOOK = "worktree-env.rb"
1818
+
1819
+ def initialize(coord_dir)
1820
+ @coord_dir = coord_dir
1821
+ end
1822
+
1823
+ def write(dir, slug)
1824
+ slot = Slots.new(@coord_dir).assign(slug)
1825
+ FileUtils.mkdir_p(File.join(dir, MAF_DIR))
1826
+ File.write(File.join(dir, FILE), base_lines(slot) + hook_lines(dir, slot))
1827
+ end
1828
+
1829
+ private
1830
+
1831
+ def base_lines(slot)
1832
+ "export COORD_DIR=#{@coord_dir}\nexport TASKRC=#{File.join(@coord_dir, "taskrc")}\n" \
1833
+ "export COORD_SLOT=#{slot}\n#{bin_lines}"
1834
+ end
1835
+
1836
+ # Same two lines as the env.sh of the main project: MAF_BIN and PATH.
1837
+ def bin_lines
1838
+ "export MAF_BIN=#{File.join(File.dirname(@coord_dir), "bin")}\n" \
1839
+ "case \":$PATH:\" in *\":$MAF_BIN:\"*) ;; *) export PATH=\"$MAF_BIN:$PATH\" ;; esac\n"
1840
+ end
1841
+
1842
+ def hook_lines(dir, slot)
1843
+ hook = File.join(@coord_dir, HOOK)
1844
+ return "" unless File.exist?(hook)
1845
+
1846
+ run_hook(hook, { "COORD_SLOT" => slot.to_s, "COORD_WORKTREE" => dir })
1847
+ end
1848
+
1849
+ # Keep only `export NAME=VALUE` lines. A shell that sources .maf/env.sh
1850
+ # then runs nothing else the hook prints.
1851
+ def run_hook(hook, env)
1852
+ output = IO.popen(env, [RbConfig.ruby, hook], &:read)
1853
+ return output.lines.grep(/\Aexport \w+=/).join if $?.success?
1854
+
1855
+ warn "coord: #{hook} failed. .maf/env.sh has no project variables."
1856
+ ""
1857
+ end
1858
+ end
1859
+
1860
+ # FastForward moves a reused worker branch to origin/<base branch>, so a
1861
+ # long-lived worktree does not drift. It acts only on a worker/ branch that
1862
+ # is checked out in a clean worktree, and only for a fast-forward.
1863
+ # Otherwise it skips and logs the reason. A project without origin is skipped
1864
+ # without a log line.
1865
+ class FastForward
1866
+ def initialize(dir, branch, base)
1867
+ @dir, @branch, @base = dir, branch, base
1868
+ end
1869
+
1870
+ def run
1871
+ return unless @branch.start_with?("worker/") && Git.run(@dir, "remote", "get-url", "origin")
1872
+
1873
+ reason = checks.find { |check, _| !check.call }&.last
1874
+ reason ? warn("coord: no fast-forward of #{@branch}: #{reason}") : merge
1875
+ end
1876
+
1877
+ private
1878
+
1879
+ def checks
1880
+ [[-> { head == @branch }, "#{@branch} is not checked out"],
1881
+ [-> { Git.clean?(@dir) }, "the worktree has uncommitted changes"],
1882
+ [-> { Git.run(@dir, "fetch", "-q", "origin", @base) }, "git fetch origin #{@base} failed"],
1883
+ [-> { Git.run(@dir, "merge-base", "--is-ancestor", "HEAD", target) },
1884
+ "#{@branch} has commits that #{target} lacks"]]
1885
+ end
1886
+
1887
+ def merge
1888
+ return if rev("HEAD") == rev(target)
1889
+
1890
+ Git.run(@dir, "merge", "-q", "--ff-only", target) && puts("fast-forwarded #{@branch} to #{target}")
1891
+ end
1892
+
1893
+ def target = "origin/#{@base}"
1894
+ def head = git_out("branch", "--show-current")
1895
+ def rev(name) = git_out("rev-parse", "-q", "--verify", name)
1896
+ def git_out(*args) = IO.popen(["git", "-C", @dir, *args], err: File::NULL, &:read).strip
1897
+ end
1898
+
1899
+ # Worktree creates a per-worker git worktree and branch, so "one branch or
1900
+ # worktree per worker" (the contract's own rule) is a command, not a manual
1901
+ # step. The worktree shares the main project's .maf/coordination/ dir and task
1902
+ # board through COORD_DIR/TASKRC exported by an untracked .maf/env.sh, not
1903
+ # by symlinking tracked paths (a symlink would dirty the worktree and, if
1904
+ # committed, break the coordination dir on merge).
1905
+ #
1906
+ # All worktrees live inside the project under .maf/worktrees/<slug>.
1907
+ # .maf/worktrees/ is gitignored, so agents stay within the project directory
1908
+ # and need no cross-directory file access permissions. maf start finds the
1909
+ # worktree with the same shared formula.
1910
+ class Worktree
1911
+ ENV_FILE = WorktreeEnv::FILE
1912
+ # The graph folder at the project root. The exclude line has no trailing
1913
+ # slash, so it also matches the symlink.
1914
+ GRAPH_DIR = "graphify-out"
1915
+ 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
1920
+
1921
+ def self.dir_for(root, slug) = Maf::Shared::Project.worktree_dir(root, slug)
1922
+
1923
+ def initialize(project_root)
1924
+ @root = project_root
1925
+ end
1926
+
1927
+ # Idempotent: a repeat call must not abort just because a previous call
1928
+ # (or a `git worktree remove` that left the branch behind) already did
1929
+ # part of the work.
1930
+ def create(role, worker)
1931
+ slug = self.class.slug(role, worker)
1932
+ checkout(slug, "worker/#{slug}", base_branch)
1933
+ end
1934
+
1935
+ # A new worker branch starts on the base branch, not on the HEAD of the
1936
+ # main checkout. That HEAD can be a task branch of another role.
1937
+ def base_branch
1938
+ name = Git.base_branch(@root, Manifest.new(File.join(@root, ".maf/config.json")))
1939
+ name if Git.branch?(@root, name)
1940
+ end
1941
+
1942
+ # BASE is the start point of a new branch. Without BASE, git uses HEAD.
1943
+ def checkout(slug, branch, base = nil)
1944
+ dir = self.class.dir_for(@root, slug)
1945
+ return reuse(dir, branch) if Dir.exist?(dir)
1946
+
1947
+ add_worktree(dir, branch, base)
1948
+ finish(dir, branch, "worktree #{dir} on branch #{branch}")
1949
+ end
1950
+
1951
+ def add_worktree(dir, branch, base)
1952
+ FileUtils.mkdir_p(File.dirname(dir))
1953
+ ok = system("git", "-C", @root, "worktree", "add", *add_args(dir, branch, base))
1954
+ abort "coord: git worktree add failed" unless ok
1955
+ end
1956
+
1957
+ # A WORKER that already starts with the role ("frontend-developer-2") is
1958
+ # the full name. Joining it with the role again repeats the role.
1959
+ def self.slug(role, worker)
1960
+ return worker if worker&.start_with?("#{role}-")
1961
+
1962
+ [role, worker].compact.join("-")
1963
+ end
1964
+
1965
+ private
1966
+
1967
+ # No `-b` when the branch already exists (e.g. its worktree was removed
1968
+ # without deleting the branch) -- `git worktree add -b` refuses to reuse
1969
+ # a branch name, `git worktree add` without `-b` checks it out instead.
1970
+ def add_args(dir, branch, base)
1971
+ Git.branch?(@root, branch) ? [dir, branch] : [dir, "-b", branch, *base]
1972
+ end
1973
+
1974
+ def reuse(dir, branch)
1975
+ unless worktree?(dir)
1976
+ abort "coord: #{dir} exists but is not a git worktree. Remove it or choose a different slug."
1977
+ end
1978
+
1979
+ FastForward.new(dir, branch, Git.base_branch(@root, Manifest.new(File.join(@root, ".maf/config.json")))).run
1980
+ finish(dir, branch, "worktree #{dir} already exists, reusing (branch #{branch})")
1981
+ end
1982
+
1983
+ def worktree?(dir)
1984
+ system("git", "-C", dir, "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
1985
+ end
1986
+
1987
+ def finish(dir, branch, message)
1988
+ ensure_runtime_files(dir)
1989
+ link_graph(dir)
1990
+ exclude_local_files(dir)
1991
+ WorktreeEnv.new(File.join(@root, MAF_DIR, "coordination")).write(dir, File.basename(dir))
1992
+ puts message, SOURCE_HINT
1993
+ end
1994
+
1995
+ SOURCE_HINT = "In that worktree, run: source #{ENV_FILE} (shares this project's .maf/coordination/ " \
1996
+ "and task board)"
1997
+
1998
+ # The flow is not committed: it is a tool, not a part of the project. So
1999
+ # a fresh worktree checks out none of these files. Copy the main
2000
+ # worktree's copy of each missing file. A reused worktree
2001
+ # gets files that were added after it was created. An existing file is
2002
+ # never overwritten, so a change by the agent stays.
2003
+ def ensure_runtime_files(dir)
2004
+ paths = (RUNTIME_FILES + bin_files + copy_list).uniq
2005
+ paths.each { |path| copy_missing(File.join(@root, path), File.join(dir, path)) }
2006
+ end
2007
+
2008
+ # Every script of .maf/bin, not only coord: dispatch mode needs the dispatcher.
2009
+ # The scripts load the shared library from .maf/lib.
2010
+ def bin_files
2011
+ Dir.glob("#{MAF_DIR}/{bin/*,lib/**/*.rb}", base: @root).select { |path| File.file?(File.join(@root, path)) }
2012
+ end
2013
+
2014
+ # copy_to_worktree in .maf/config.json lists more host files for each
2015
+ # worktree, for example .env. Only paths inside the project are copied.
2016
+ def copy_list
2017
+ list = Manifest.new(File.join(@root, ".maf/config.json")).copy_to_worktree
2018
+ outside = list.select { |path| path.start_with?("/") || path.split("/").include?("..") }
2019
+ warn "coord: copy_to_worktree skips paths outside the project: #{outside.join(", ")}" unless outside.empty?
2020
+ list - outside
2021
+ end
2022
+
2023
+ def copy_missing(src, dest)
2024
+ return if File.exist?(dest) || !File.exist?(src)
2025
+
2026
+ FileUtils.mkdir_p(File.dirname(dest))
2027
+ FileUtils.cp(src, dest, preserve: true)
2028
+ end
2029
+
2030
+ # The graph is not committed. A relative symlink gives the worktree the
2031
+ # graph of the main checkout at the default path of graphify, so a query
2032
+ # or a saved note in the worktree uses the main graph.
2033
+ def link_graph(dir)
2034
+ link = File.join(dir, GRAPH_DIR)
2035
+ return if File.exist?(link) || File.symlink?(link)
2036
+
2037
+ FileUtils.mkdir_p(File.join(@root, GRAPH_DIR))
2038
+ File.symlink(Pathname.new(File.join(@root, GRAPH_DIR)).relative_path_from(Pathname.new(dir)).to_s, link)
2039
+ end
2040
+
2041
+ # .maf/env.sh holds absolute host paths, and the graph link points into
2042
+ # the main checkout. Neither may be committed. Git has no per-worktree
2043
+ # exclude, and .gitignore may not be committed yet, so add both to the
2044
+ # repository's shared local exclude instead. bootstrap excludes the graph
2045
+ # too; an install from before the graph link lacks that line.
2046
+ def exclude_local_files(dir)
2047
+ exclude = Maf::Shared::GitExclude.path(dir)
2048
+ missing = exclude && unlisted(exclude)
2049
+ return if missing.nil? || missing.empty?
2050
+
2051
+ FileUtils.mkdir_p(File.dirname(exclude))
2052
+ File.open(exclude, "a") { |file| file.puts(missing) }
2053
+ end
2054
+
2055
+ def unlisted(exclude) = LOCAL_EXCLUDES - (File.exist?(exclude) ? File.read(exclude).lines.map(&:strip) : [])
2056
+ end
2057
+
2058
+ # CLI dispatches commands. Keep methods small; collaborators do the work.
2059
+ # Args wraps the argument list with small parsing helpers.
2060
+ class Args
2061
+ def initialize(argv)
2062
+ @argv = argv
2063
+ end
2064
+
2065
+ def flag?(name)
2066
+ index = @argv.index(name)
2067
+ return false unless index
2068
+
2069
+ @argv.delete_at(index)
2070
+ true
2071
+ end
2072
+
2073
+ def value_of(name)
2074
+ index = @argv.index(name)
2075
+ return nil unless index
2076
+
2077
+ @argv.delete_at(index)
2078
+ @argv.delete_at(index)
2079
+ end
2080
+
2081
+ def ttl = (value_of("--ttl") || 3600).to_i
2082
+ def interval = (value_of("--interval") || DEFAULT_POLL_INTERVAL).to_i
2083
+ def timeout = (v = value_of("--timeout")) && v.to_i
2084
+
2085
+ def require_arg(command, name)
2086
+ shift || abort("usage: coord #{command} #{name}")
2087
+ end
2088
+
2089
+ def require_text(command)
2090
+ text = @argv.join(" ")
2091
+ abort "usage: coord #{command} ... TEXT" if text.empty?
2092
+
2093
+ text
2094
+ end
2095
+
2096
+ def parse_options(spec)
2097
+ opts = {}
2098
+ OptionParser.new { |o| spec.each { |flag, key| o.on("#{flag} VALUE") { |v| opts[key] = v } } }.parse!(@argv)
2099
+ opts
2100
+ end
2101
+
2102
+ def shift = @argv.shift
2103
+ def first = @argv.first
2104
+ def empty? = @argv.empty?
2105
+ def join = @argv.join(" ")
2106
+
2107
+ # Splits argv at the first literal "--" and returns the words after it,
2108
+ # leaving only the option part in @argv. This keeps a wrapped command's own
2109
+ # flags (e.g. "--ttl") out of coord's option parsing.
2110
+ def take_command
2111
+ index = @argv.index("--")
2112
+ return nil unless index
2113
+
2114
+ command = @argv[(index + 1)..]
2115
+ @argv.replace(@argv[0...index])
2116
+ command
2117
+ end
2118
+ end
2119
+
2120
+ # Each *Commands module implements coord subcommands. State lives on the CLI instance.
2121
+ # Task commands add, list, show, and export tasks.
2122
+ module TaskCommands
2123
+ def cmd_init
2124
+ setup.ensure_dirs
2125
+ setup.ensure_taskrc
2126
+ puts ".maf/coordination/ ready (#{@coord_dir})"
2127
+ end
2128
+
2129
+ def cmd_add
2130
+ opts = add_options
2131
+ id = tasks.add(**opts)
2132
+ event_log.append("add", @worker, "#{id} #{opts[:title]}")
2133
+ after_add(opts, id)
2134
+ end
2135
+
2136
+ def after_add(opts, id)
2137
+ conflicts.warn_with(opts[:scope], exclude: id)
2138
+ report_unstaffed(opts[:role], id)
2139
+ puts id
2140
+ end
2141
+
2142
+ def add_options
2143
+ opts = @args.parse_options(ADD_OPTS)
2144
+ opts[:title] ||= @args.join unless @args.empty?
2145
+ abort "usage: coord add --role R --scope S --title T" if opts[:title].to_s.empty?
2146
+ opts[:goal] &&= goal_uuid(opts[:goal])
2147
+ opts
2148
+ end
2149
+
2150
+ def cmd_next
2151
+ flags = next_flags
2152
+ role = require_role!(@args.shift || @role, "next ROLE, or export COORD_ROLE")
2153
+ refuse_lead!(role, "has no tasks to wait for") if flags[:wait]
2154
+ return board.mine_tasks(role, @worker) if flags[:mine]
2155
+
2156
+ flags[:wait] ? wait_next(role, flags) : board.next_tasks(role, @worker)
2157
+ end
2158
+
2159
+ # Every flag must be parsed out before the positional `shift`: `shift`
2160
+ # takes whatever token is first, flag or not, so a flag left unparsed
2161
+ # (e.g. --timeout) would be swallowed as the ROLE argument.
2162
+ def next_flags
2163
+ { mine: @args.flag?("--mine"), wait: @args.flag?("--wait"), interval: @args.interval, timeout: @args.timeout }
2164
+ end
2165
+
2166
+ # A fresh Tasks instance per check: Tasks#pending memoizes (so one
2167
+ # `coord board` run only shells out to `task` once), but reusing the
2168
+ # single Wiring-memoized instance across poll iterations would freeze
2169
+ # the result at the first, possibly-empty, snapshot forever.
2170
+ # A message ends the wait too: an agent blocked here must not miss it.
2171
+ def wait_next(role, flags)
2172
+ wait_for(flags[:interval], flags[:timeout], -> { Tasks.new.next_for(role, @worker) + messages.files(role) }) do
2173
+ board.next_tasks(role, @worker)
2174
+ announce_messages(role)
2175
+ end
2176
+ end
2177
+
2178
+ def announce_messages(role)
2179
+ count = messages.files(role).size
2180
+ puts "#{count} unread message#{"s" if count != 1}. Run coord inbox." if count.positive?
2181
+ end
2182
+
2183
+ def cmd_conflicts = conflicts.report
2184
+
2185
+ def cmd_show
2186
+ puts TaskView.lines(find_task!(@args.require_arg("show", "ID")))
2187
+ end
2188
+
2189
+ def cmd_start_task
2190
+ task = find_task!(@args.require_arg("start-task", "ID"))
2191
+ base = task["goalid"] ? Goals.branch(task["goalid"]) : Git.base_branch(@project_root, manifest)
2192
+ TaskBranch.new(@project_root).start(task["uuid"], base)
2193
+ event_log.append("start-task", @worker, task["uuid"])
2194
+ end
2195
+
2196
+ def cmd_status = board.status
2197
+ def cmd_who = presence.report
2198
+ def cmd_board = board.board
2199
+
2200
+ def cmd_export
2201
+ tasks.export_to(paths.export_path)
2202
+ puts "export -> #{paths.export_path}"
2203
+ end
2204
+
2205
+ def cmd_help = puts(Usage::TEXT)
2206
+
2207
+ private
2208
+
2209
+ UNSTAFFED = "No worker runs role %s."
2210
+
2211
+ # With a worker registry (.maf/coordination/workers.json), a task for a role
2212
+ # without a worker waits forever. Tell the project manager once per role.
2213
+ def report_unstaffed(role, id)
2214
+ return if role.nil? || staffed?(role) || unstaffed_notice?(role)
2215
+
2216
+ event_log.append("unstaffed", @worker, role)
2217
+ text = "#{format(UNSTAFFED, role)} Task #{id} waits. Add a worker: maf prepare HARNESS #{role}_1 --dispatch"
2218
+ return warn("coord: #{text}") unless manifest.agents.any? { |a| a["role"] == "project-manager" }
2219
+
2220
+ deliver("project-manager", "coord", text)
2221
+ end
2222
+
2223
+ def staffed?(role)
2224
+ path = File.join(paths.coord_dir, "workers.json")
2225
+ !File.exist?(path) || JSON.parse(File.read(path)).values.any? { |entry| entry["role"] == role }
2226
+ end
2227
+
2228
+ # The event log records each notice. A message in the inbox is not enough:
2229
+ # a project without a project-manager role gets no message at all, and the
2230
+ # log is the only place the notice is remembered.
2231
+ def unstaffed_notice?(role)
2232
+ path = paths.log_path
2233
+ return false unless File.exist?(path)
2234
+
2235
+ File.readlines(path).any? { |line| unstaffed_line?(line, role) }
2236
+ end
2237
+
2238
+ def unstaffed_line?(line, role)
2239
+ _time, action, _worker, detail = line.split("\t")
2240
+ action == "unstaffed" && detail.to_s.strip == role
2241
+ end
2242
+ end
2243
+
2244
+ # Claim commands take, release, note, and finish a task.
2245
+ module ClaimCommands
2246
+ def cmd_claim
2247
+ id = @args.require_arg("claim", "ID")
2248
+ force = @args.flag?("--force")
2249
+ require_worker!(@worker, "claim ID (needs COORD_WORKER or COORD_ROLE)")
2250
+ refuse_lead!(@role, "never claims a task")
2251
+ claimed(id, locks.with_mutex("task-#{id}", worker: @worker) { tasks.claim(id, @worker, force: force) })
2252
+ end
2253
+
2254
+ def claimed(id, holder)
2255
+ notify_prior_holder(id, holder) if holder && holder.worker != @worker
2256
+ event_log.append("claim", @worker, id)
2257
+ puts "claimed #{id} by #{@worker}"
2258
+ end
2259
+
2260
+ def cmd_unclaim
2261
+ id = @args.require_arg("unclaim", "ID")
2262
+ tasks.unclaim(id)
2263
+ event_log.append("unclaim", @worker, id)
2264
+ puts "unclaimed #{id}"
2265
+ end
2266
+
2267
+ def cmd_done
2268
+ force = @args.flag?("--force")
2269
+ id = @args.require_arg("done", "ID")
2270
+ task = tasks.find(id)
2271
+ refuse_done!(task, force)
2272
+ finish_task(id, task)
2273
+ end
2274
+
2275
+ def refuse_done!(task, force)
2276
+ refuse_foreign!(task, force)
2277
+ refuse_stale!(task) unless force
2278
+ refuse_unverified!
2279
+ end
2280
+
2281
+ def finish_task(id, task)
2282
+ tasks.done(id)
2283
+ event_log.append("done", @worker, id)
2284
+ report_done(task)
2285
+ WorkMemory.new(main_root).save(task, "useful", WorkMemory.notes(task))
2286
+ puts "done #{id}"
2287
+ end
2288
+
2289
+ # A dispatched architect starts only on a message. Without this notice,
2290
+ # the architect never learns that a task is ready for its check.
2291
+ def report_done(task)
2292
+ return if [nil, "architect", Goals::ROLE].include?(task["role"]) || @role == "architect"
2293
+
2294
+ text = "Task #{task["uuid"]} is done: #{task["description"]}. Check the diff and the TESTS line."
2295
+ deliver("architect", @worker, text)
2296
+ end
2297
+
2298
+ def cmd_annotate
2299
+ id = @args.require_arg("annotate", "ID")
2300
+ tasks.annotate(id, @args.require_text("annotate"))
2301
+ event_log.append("annotate", @worker, id)
2302
+ puts "annotated #{id}"
2303
+ end
2304
+
2305
+ LESSON_USAGE = "usage: coord lesson ID dead_end|corrected TEXT..."
2306
+ LESSON_FAILED = "coord: no graph in graphify-out/, or graphify failed. The lesson is not saved."
2307
+
2308
+ # A dead end saves the text as the answer. A correction saves the task
2309
+ # notes as the answer and the text as the right way.
2310
+ def cmd_lesson
2311
+ id = @args.require_arg("lesson", "ID")
2312
+ outcome = @args.shift
2313
+ abort LESSON_USAGE unless %w[dead_end corrected].include?(outcome)
2314
+
2315
+ save_lesson(find_task!(id), outcome, @args.require_text("lesson"))
2316
+ end
2317
+
2318
+ def save_lesson(task, outcome, text)
2319
+ memory = WorkMemory.new(main_root)
2320
+ answer, correction = outcome == "dead_end" ? [text, nil] : [WorkMemory.notes(task), text]
2321
+ abort LESSON_FAILED unless memory.save(task, outcome, answer, correction)
2322
+
2323
+ event_log.append("lesson", @worker, "#{task["uuid"]} #{outcome}")
2324
+ puts "lesson saved for #{task["uuid"]}"
2325
+ end
2326
+
2327
+ def find_task!(id)
2328
+ task = tasks.find(id)
2329
+ raise Error, "no task found" if task["uuid"].nil?
2330
+
2331
+ task
2332
+ end
2333
+
2334
+ private
2335
+
2336
+ STALE = "coord: branch %<task>s lacks the head of %<goal>s. In your worktree, run `git merge %<goal>s`. " \
2337
+ "Run the task tests again. Then run coord done again. Use --force only if the merge is not wanted."
2338
+
2339
+ # A stalled run can wake up after its claim was released. It must not close
2340
+ # a task that is closed or that another worker holds now.
2341
+ def refuse_foreign!(task, force)
2342
+ status = task["status"]
2343
+ raise Error, "task #{task["uuid"]} is already #{status}. Stop work on it." if status && status != "pending"
2344
+
2345
+ holder = task["worker"].to_s
2346
+ return if force || holder.empty? || holder == @worker
2347
+
2348
+ raise Error, "task #{task["uuid"]} belongs to #{holder} now. Stop work on it (use --force to close it anyway)."
2349
+ end
2350
+
2351
+ def refuse_stale!(task)
2352
+ goal, branch = TaskSync.new(@project_root).stale(task)
2353
+ abort format(STALE, task: branch, goal: goal) if goal
2354
+ end
2355
+
2356
+ VERIFY_FAILED = "coord: the verify command failed: %<command>s\n%<tail>s\n" \
2357
+ "Fix the failures. Then run coord done again. --force does not skip this check."
2358
+
2359
+ # The verify command in .maf/config.json is a mechanical gate for done.
2360
+ # It runs in the current worktree. No key means no check.
2361
+ def refuse_unverified!
2362
+ command = manifest.verify
2363
+ return if command.empty?
2364
+
2365
+ output = IO.popen(["sh", "-c", command], err: %i[child out], &:read)
2366
+ abort format(VERIFY_FAILED, command: command, tail: output.lines.last(20).join.strip) unless $?.success?
2367
+ end
2368
+
2369
+ def notify_prior_holder(id, holder)
2370
+ warn "coord: task #{id} was claimed from #{holder.worker} by #{@worker}"
2371
+ # Send to the role's inbox (what agents poll with `coord inbox`), not the
2372
+ # worker id, which no agent reads. Name the exact worker in the body.
2373
+ to = holder.role.empty? ? holder.worker : holder.role
2374
+ messages.send_message(to, @worker, "task #{id} was claimed from worker #{holder.worker} by #{@worker}")
2375
+ end
2376
+ end
2377
+
2378
+ # Message commands send messages and push them to a role.
2379
+ module MessageCommands
2380
+ def cmd_msg
2381
+ from = require_role!(@args.value_of("--from") || @role, "msg --from A, or export COORD_ROLE")
2382
+ task = @args.value_of("--task")&.then { |id| find_task!(id) }
2383
+ fyi = @args.flag?("--fyi")
2384
+ name = @args.require_arg("msg", "TO")
2385
+ deliver_to(name, from, task_message(task, from, @args.require_text("msg")), fyi: fyi)
2386
+ end
2387
+
2388
+ # An FYI message wakes no one, so it needs no push.
2389
+ def deliver_to(name, from, text, fyi: false)
2390
+ role, worker = recipient(name)
2391
+ path = messages.send_message(role, from, text, worker: worker, fyi: fyi)
2392
+ push(role, from, path) unless fyi
2393
+ event_log.append("msg", @worker, "to #{name}#{" (fyi)" if fyi}: #{text}")
2394
+ end
2395
+
2396
+ # A worker can stop before it reads its mail. The note on the task stays
2397
+ # for the worker that continues the task.
2398
+ def task_message(task, from, text)
2399
+ return text unless task
2400
+
2401
+ tasks.annotate(task["uuid"], "MSG from #{from}: #{text}")
2402
+ "Task #{task["uuid"]}: #{text}\nThis text is also a note on the task (coord show #{task["uuid"]})."
2403
+ end
2404
+
2405
+ # Returns [role, worker]. A worker has no inbox of its own: its mail goes
2406
+ # to its role inbox. A name that is no role and no worker is a typo.
2407
+ def recipient(name)
2408
+ roles = Roster.new(paths, tasks).roles | LEADS
2409
+ return [name, nil] if roles.include?(name) || roles == LEADS
2410
+
2411
+ role = roles.find { |known| name.match?(/\A#{Regexp.escape(known)}-\d+\z/) }
2412
+ role ? [role, name] : raise(Error, "unknown recipient '#{name}'. Use a role or a worker: #{roles.join(", ")}")
2413
+ end
2414
+
2415
+ # Broadcast sends a message to every known role except the sender (see
2416
+ # Roster). The architect uses this for scope changes or blockers that
2417
+ # affect everyone, instead of messaging each role individually.
2418
+ def cmd_broadcast
2419
+ from = require_role!(@args.value_of("--from") || @role, "broadcast --from A, or export COORD_ROLE")
2420
+ audience = Audiences.pick(@args.value_of("--to") || "workers")
2421
+ text = @args.require_text("broadcast")
2422
+ deliver_broadcast(from, text, broadcast_roles(from, audience))
2423
+ end
2424
+
2425
+ def broadcast_roles(from, audience)
2426
+ roles = Roster.new(paths, tasks).roles.select(&audience) - [from]
2427
+ abort "coord: no other roles to broadcast to" if roles.empty?
2428
+
2429
+ roles
2430
+ end
2431
+
2432
+ # Hooks shows which role hooks are installed. With a ROLE argument, shows
2433
+ # just that role's hook. The hooks themselves are plain shell scripts at
2434
+ # .maf/coordination/message-hooks/<role>.sh, fired by `coord msg` and `coord broadcast`.
2435
+ def cmd_hooks
2436
+ return puts("no message hooks directory (run coord init)") unless Dir.exist?(paths.message_hooks_dir)
2437
+
2438
+ found = hook_files(@args.shift)
2439
+ return puts("no hooks installed") if found.empty?
2440
+
2441
+ found.each { |path| puts hook_line(path) }
2442
+ end
2443
+
2444
+ private
2445
+
2446
+ def deliver(to, from, text)
2447
+ path = messages.send_message(to, from, text)
2448
+ push(to, from, path)
2449
+ end
2450
+
2451
+ def deliver_broadcast(from, text, roles)
2452
+ results = messages.broadcast(from, text, roles)
2453
+ results.each { |role, path| push(role, from, path) }
2454
+ event_log.append("broadcast", @worker, text)
2455
+ end
2456
+
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, " \
2459
+ "or add an executable .maf/coordination/message-hooks/%<role>s.sh"
2460
+
2461
+ # A message is pushed by a hook or read by a live session. Without both,
2462
+ # it waits unseen. Say so at send time, not hours later.
2463
+ def push(role, from, path)
2464
+ return if hooks.run(role, from: from, message_path: path) || presence.live?(role)
2465
+
2466
+ warn format(NO_PUSH, role: role)
2467
+ end
2468
+
2469
+ def hook_files(role)
2470
+ found = Dir.glob(File.join(paths.message_hooks_dir, "*.sh")).sort
2471
+ role ? found.select { |path| File.basename(path, ".sh") == role } : found
2472
+ end
2473
+
2474
+ def hook_line(path)
2475
+ status = File.executable?(path) ? "active" : "inactive (chmod +x)"
2476
+ "#{File.basename(path)}\t#{status}\t#{path}"
2477
+ end
2478
+ end
2479
+
2480
+ # Inbox commands escalate problems and wait for messages.
2481
+ module InboxCommands
2482
+ ESCALATE_PM = "project-manager"
2483
+
2484
+ # A worker cannot fix a problem outside its task, such as a missing tool
2485
+ # or a refused guard. The project manager asks the user. The architect
2486
+ # gets a copy, because the task waits.
2487
+ def cmd_escalate
2488
+ task = @args.value_of("--task")
2489
+ text = @args.require_text("escalate")
2490
+ abort "coord: the project manager cannot escalate to itself. Ask the user." if @role == ESCALATE_PM
2491
+ tasks.annotate(find_task!(task)["uuid"], "ESCALATED: #{text}") if task
2492
+ escalate(task, text)
2493
+ end
2494
+
2495
+ def escalate(task, text)
2496
+ send_escalation(task, text)
2497
+ event_log.append("escalate", @worker, "#{task} #{text}".strip)
2498
+ end
2499
+
2500
+ def send_escalation(task, text)
2501
+ targets = escalation_targets
2502
+ abort "coord: no project manager and no other lead to receive the escalation. Ask the user." if targets.empty?
2503
+ targets.each { |to| deliver(to, @role, escalation_text(task, text)) }
2504
+ end
2505
+
2506
+ def escalation_text(task, text)
2507
+ where = task ? " (task #{task})" : ""
2508
+ "ESCALATION from #{@worker}#{where}: #{text}"
2509
+ end
2510
+
2511
+ # Without a project manager in the project, the architect is the only receiver.
2512
+ def escalation_targets
2513
+ pm = manifest.agents.any? { |agent| agent["role"] == ESCALATE_PM }
2514
+ [(ESCALATE_PM if pm), ("architect" unless @role == "architect")].compact
2515
+ end
2516
+
2517
+ AWAIT_ARMED = "coord: the stop hook waits up to %<minutes>d minutes for work for %<role>s. " \
2518
+ "Tell the user that you wait and that Esc ends the wait. Then end your turn."
2519
+ AWAIT_DISPATCHED = "coord: a dispatched run cannot wait. Stop: the dispatcher starts a run on new work."
2520
+
2521
+ def cmd_await
2522
+ abort AWAIT_DISPATCHED if @env["COORD_DISPATCHED"]
2523
+ role = require_role!(@role, "await, export COORD_ROLE")
2524
+ seconds = @args.timeout || Await::DEFAULT_SECONDS
2525
+ Await.new(paths, require_worker!(@worker, "await, export COORD_WORKER")).arm(role, seconds)
2526
+ puts format(AWAIT_ARMED, minutes: seconds / 60, role: role)
2527
+ end
2528
+
2529
+ def cmd_inbox
2530
+ flags = inbox_flags
2531
+ role = require_role!(@args.shift || @role, "inbox ROLE, or export COORD_ROLE")
2532
+ return messages.inbox(role, peek: flags[:peek], all: flags[:all]) unless flags[:wait]
2533
+
2534
+ InboxWaiter.new(paths, role).hold { wait_inbox(role, flags) }
2535
+ end
2536
+
2537
+ # Same ordering requirement as cmd_next: parse every flag before the
2538
+ # positional `shift`, or an unparsed flag gets read as the ROLE.
2539
+ def inbox_flags
2540
+ { wait: @args.flag?("--wait"), interval: @args.interval, timeout: @args.timeout,
2541
+ peek: @args.flag?("--peek"), all: @args.flag?("--all") }
2542
+ end
2543
+
2544
+ def wait_inbox(role, flags)
2545
+ wait_for(flags[:interval], flags[:timeout], -> { messages.wake_files(role) }) do
2546
+ messages.inbox(role, peek: flags[:peek], all: flags[:all])
2547
+ end
2548
+ end
2549
+ end
2550
+
2551
+ # Goal commands create, sync, land, and close goals.
2552
+ module GoalCommands
2553
+ GOAL_COMMANDS = { "add" => :goal_add, "list" => :goal_list, "show" => :goal_show, "done" => :goal_done,
2554
+ "sync" => :goal_sync, "pr" => :goal_pr }.freeze
2555
+
2556
+ def cmd_goal
2557
+ sub = @args.shift
2558
+ abort "usage: coord goal add|list|show|sync|pr|done" unless GOAL_COMMANDS.key?(sub)
2559
+
2560
+ send(GOAL_COMMANDS[sub])
2561
+ end
2562
+
2563
+ # Only the architect lands, one squash at a time for each goal.
2564
+ def cmd_land
2565
+ subject = @args.value_of("--subject")
2566
+ task = find_task!(@args.require_arg("land", "ID"))
2567
+ land = Land.new(main_root, task)
2568
+ commit = locks.with_mutex("land-#{Goals.short(task["goalid"])}", worker: @worker) { land.run(subject) }
2569
+ record_landing(task, commit, land)
2570
+ end
2571
+
2572
+ def record_landing(task, commit, land)
2573
+ where = commit ? "#{commit} on #{land.goal_branch}" : "no changes"
2574
+ tasks.annotate(task["uuid"], "LANDED: #{where}")
2575
+ event_log.append("land", @worker, "#{task["uuid"]} #{where}")
2576
+ puts "landed #{land.branch}: #{where}"
2577
+ end
2578
+
2579
+ private
2580
+
2581
+ def goals = @goals ||= Goals.new(tasks)
2582
+
2583
+ def goal_list = goals.list
2584
+
2585
+ # Older taskrc files lack the `goal` UDA. Add it before the first use.
2586
+ def goal_uuid(id)
2587
+ setup.ensure_taskrc
2588
+ goals.find!(id)["uuid"]
2589
+ end
2590
+ def goal_show = goals.show(@args.require_arg("goal show", "ID"))
2591
+
2592
+ def goal_sync
2593
+ goal = goals.find!(@args.require_arg("goal sync", "ID"))
2594
+ goal_sync_for(goal).run
2595
+ event_log.append("goal-sync", @worker, goal["description"])
2596
+ end
2597
+
2598
+ def goal_sync_for(goal) = GoalSync.new(main_root, goal["uuid"], Git.base_branch(main_root, manifest))
2599
+
2600
+ def goal_done
2601
+ id = @args.require_arg("goal done", "ID")
2602
+ refuse_unsynced!(goals.find!(id))
2603
+ goal = goals.done(id)
2604
+ event_log.append("goal-done", @worker, goal["description"])
2605
+ puts "goal #{id} done. The goal branch is #{Goals.branch(goal["uuid"])}."
2606
+ end
2607
+
2608
+ def goal_add
2609
+ opts = @args.parse_options(GOAL_OPTS)
2610
+ opts[:title] ||= @args.join unless @args.empty?
2611
+ abort "usage: coord goal add --title T [--ref R] [--base BRANCH]" if opts[:title].to_s.empty?
2612
+ setup.ensure_taskrc
2613
+ create_goal(opts, opts[:base] || Git.base_branch(main_root, manifest))
2614
+ end
2615
+
2616
+ def create_goal(opts, base)
2617
+ raise Error, "base branch #{base} does not exist" unless Git.branch?(main_root, base)
2618
+
2619
+ 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)
2621
+ event_log.append("goal", @worker, opts[:title])
2622
+ puts uuid
2623
+ end
2624
+
2625
+ UNSYNCED = "coord: %<branch>s lacks the head of %<ref>s. Run coord goal sync %<id>s. " \
2626
+ "Run the merge suite again. Then run coord goal done again."
2627
+
2628
+ # A goal without its branch (an old goal) has nothing to check.
2629
+ def refuse_unsynced!(goal)
2630
+ sync = goal_sync_for(goal)
2631
+ branch = Goals.branch(goal["uuid"])
2632
+ return unless Git.branch?(main_root, branch) && sync.behind?
2633
+
2634
+ abort format(UNSYNCED, branch: branch, ref: sync.ref, id: goal["uuid"])
2635
+ end
2636
+ end
2637
+
2638
+ # Review commands open goal pull requests and forward their reviews.
2639
+ module ReviewCommands
2640
+ # The architect dispatcher runs `coord review-watch --once` on a timer.
2641
+ def cmd_review_watch
2642
+ once = @args.flag?("--once")
2643
+ interval = @args.interval
2644
+ require_github!
2645
+ watch_reviews(once, interval)
2646
+ end
2647
+
2648
+ def watch_reviews(once, interval)
2649
+ review_all
2650
+ loop { sleep(interval).then { review_all } } unless once
2651
+ end
2652
+
2653
+ def review_all = review_state.all.each { |uuid, entry| review_goal(uuid, entry) }
2654
+
2655
+ # One failing pull request must not stop the watch of the others.
2656
+ def record_review(uuid, entry, feed)
2657
+ review_state.put(uuid, entry.merge("seen" => feed.seen_after))
2658
+ finish_review(uuid, feed.state) if %w[MERGED CLOSED].include?(feed.state)
2659
+ end
2660
+
2661
+ def review_goal(uuid, entry)
2662
+ feed = ReviewFeed.new(github, entry)
2663
+ forward_review(uuid, entry["url"], feed) unless feed.items.empty?
2664
+ record_review(uuid, entry, feed)
2665
+ rescue Error, JSON::ParserError => e
2666
+ warn "coord: review-watch of goal #{uuid}: #{e.message}"
2667
+ end
2668
+
2669
+ REVIEW_FIX = "Create a fix task for each point. Land the fixes. Run coord goal sync %<id>s. " \
2670
+ "Then run coord goal pr %<id>s."
2671
+
2672
+ def forward_review(uuid, url, feed)
2673
+ text = "Review of goal #{uuid} (#{url}):\n#{feed.items.map { |_, body| "- #{body}" }.join("\n")}\n"
2674
+ text += format(REVIEW_FIX, id: uuid) if feed.changes_requested?
2675
+ tasks.annotate(uuid, "REVIEW: changes requested") if feed.changes_requested?
2676
+ deliver("architect", "coord", text)
2677
+ event_log.append("review", @worker, "#{uuid} #{feed.items.size} item(s)")
2678
+ end
2679
+
2680
+ def finish_review(uuid, state)
2681
+ review_state.drop(uuid)
2682
+ state == "CLOSED" ? deliver("architect", "coord", format(PR_CLOSED, id: uuid)) : close_merged_goal(uuid)
2683
+ end
2684
+
2685
+ PR_CLOSED = "The pull request of goal %<id>s was closed without a merge. Ask the project manager what to do."
2686
+ PR_MERGED = "The pull request of goal %<id>s was merged. coord closed the goal and ran coord gc."
2687
+
2688
+ def close_merged_goal(uuid)
2689
+ tasks.done(uuid) if tasks.find(uuid)["status"] == "pending"
2690
+ event_log.append("goal-merged", @worker, uuid)
2691
+ sweep(true)
2692
+ deliver("architect", "coord", format(PR_MERGED, id: uuid))
2693
+ Notify.user("Goal merged", tasks.find(uuid)["description"])
2694
+ end
2695
+
2696
+ private
2697
+
2698
+ def github = @github ||= GitHub.new(main_root, manifest.github)
2699
+ def review_state = @review_state ||= ReviewState.new(paths)
2700
+
2701
+ def require_github!
2702
+ return if github.configured?
2703
+
2704
+ raise Error, "no github section in .maf/config.json. Set bot_user, bot_email and reviewer."
2705
+ end
2706
+
2707
+ # The first call opens the pull request. A later call pushes the fixes and asks for a new review.
2708
+ def goal_pr
2709
+ require_github!
2710
+ goal = goals.find!(@args.require_arg("goal pr", "ID"))
2711
+ goals.refuse_open!(goal)
2712
+ refuse_unsynced!(goal)
2713
+ publish_goal(goal, review_state.get(goal["uuid"]))
2714
+ end
2715
+
2716
+ def publish_goal(goal, entry)
2717
+ branch = Goals.branch(goal["uuid"])
2718
+ github.push(branch)
2719
+ push_memory
2720
+ entry = entry ? update_pr(goal, entry, branch) : { "url" => open_pr(goal, branch), "seen" => [] }
2721
+ review_state.put(goal["uuid"], entry.merge("head" => Git.out(main_root, "rev-parse", branch)))
2722
+ end
2723
+
2724
+ # The notes go to the remote with each goal pull request. Another clone may
2725
+ # have pushed notes first. Each note has its own file name, so the merge has
2726
+ # no conflicts. A failed push only warns: the notes stay in the local branch.
2727
+ def push_memory
2728
+ return unless Git.branch?(main_root, WorkMemory::BRANCH)
2729
+
2730
+ merge_remote_memory(File.join(main_root, "graphify-out", "memory"))
2731
+ github.push(WorkMemory::BRANCH)
2732
+ rescue Error => e
2733
+ warn "coord: #{e.message}. The notes stay in the local branch #{WorkMemory::BRANCH}."
2734
+ end
2735
+
2736
+ def merge_remote_memory(dir)
2737
+ fetched = File.exist?(File.join(dir, ".git")) && Git.run(dir, "fetch", "-q", github.remote, WorkMemory::BRANCH)
2738
+ fetched && Git.run(dir, "merge", "-q", "--no-edit", "--allow-unrelated-histories", "FETCH_HEAD")
2739
+ end
2740
+
2741
+ def create_pr(goal, branch)
2742
+ body = PrBody.for(main_root, goal, goal_sync_for(goal).ref)
2743
+ github.create_pr(head: branch, base: Git.base_branch(main_root, manifest), title: goal["description"], body: body)
2744
+ end
2745
+
2746
+ def open_pr(goal, branch)
2747
+ url = create_pr(goal, branch)
2748
+ tasks.annotate(goal["uuid"], "PR: #{url}")
2749
+ announce_pr("Pull request ready for your review", goal, url)
2750
+ url
2751
+ end
2752
+
2753
+ def update_pr(goal, entry, branch)
2754
+ commits = Git.out(main_root, "log", "--reverse", "--format=- %h %s", "#{entry["head"]}..#{branch}")
2755
+ github.comment(entry["url"], "Coord pushed new commits. Please review again.\n\n#{commits}\n")
2756
+ github.request_review(entry["url"])
2757
+ announce_pr("Pull request updated for your review", goal, entry["url"])
2758
+ entry
2759
+ end
2760
+
2761
+ def announce_pr(title, goal, url)
2762
+ Notify.user(title, goal["description"])
2763
+ event_log.append("goal-pr", @worker, url)
2764
+ puts "#{title}: #{url}"
2765
+ end
2766
+ end
2767
+
2768
+ # Upkeep commands handle locks, the event log, worktrees, and cleanup.
2769
+ module UpkeepCommands
2770
+ def cmd_lock
2771
+ name = @args.require_arg("lock", "NAME")
2772
+ worker = require_worker!(@args.value_of("--worker") || @worker, "lock NAME --worker W, or export COORD_WORKER")
2773
+ locks.lock(name, ttl: @args.ttl, worker: worker)
2774
+ end
2775
+
2776
+ def cmd_unlock = locks.unlock(@args.require_arg("unlock", "NAME"))
2777
+
2778
+ def cmd_with_lock
2779
+ name = @args.require_arg("with-lock", "NAME")
2780
+ command = @args.take_command
2781
+ abort "usage: coord with-lock NAME [--ttl SECONDS] -- CMD..." if command.nil? || command.empty?
2782
+
2783
+ exit(run_with_lock(name, command) ? 0 : 1)
2784
+ end
2785
+
2786
+ def run_with_lock(name, command)
2787
+ started = Time.now
2788
+ ok = locks.with_lock(name, ttl: @args.ttl, command: command, worker: @worker)
2789
+ event_log.append("with-lock", @worker, "#{name} #{ok ? "ok" : "failed"} #{(Time.now - started).round}s")
2790
+ ok
2791
+ end
2792
+
2793
+ # Log shows the shared coordination history: claims, completions, unclaims,
2794
+ # and broadcasts, newest last. Optional N limits to the last N entries.
2795
+ def cmd_log
2796
+ raw = @args.shift
2797
+ count = raw && Integer(raw, exception: false)
2798
+ abort "coord: log N (positive integer)" if raw && (count.nil? || count < 1)
2799
+ event_log.read(count)
2800
+ end
2801
+
2802
+ def cmd_worktree
2803
+ role = @args.require_arg("worktree", "ROLE")
2804
+ worker = @args.shift
2805
+ warn_missing_role_files(role)
2806
+ Worktree.new(@project_root).create(role, worker)
2807
+ end
2808
+
2809
+ def cmd_gc = sweep(@args.flag?("--yes"))
2810
+
2811
+ REAPED = "Released the claim of task %<id>s. Worker %<worker>s was not seen for %<minutes>s minutes. " \
2812
+ "Check the task branch before the next worker continues."
2813
+
2814
+ def cmd_reap
2815
+ minutes = Integer(@args.value_of("--minutes") || manifest.reap_minutes)
2816
+ stale = Reaper.new(tasks, paths, minutes).stale
2817
+ return puts("no stale claims") if stale.empty?
2818
+
2819
+ stale.each { |task| reap(task, format(REAPED, id: task["uuid"], worker: task["worker"], minutes: minutes)) }
2820
+ end
2821
+
2822
+ def reap(task, text)
2823
+ tasks.unclaim(task["uuid"])
2824
+ tasks.annotate(task["uuid"], "RELEASED: #{text}")
2825
+ event_log.append("reap", @worker, "#{task["uuid"]} #{task["worker"]}")
2826
+ deliver("architect", "coord", text)
2827
+ end
2828
+
2829
+ def sweep(apply)
2830
+ items = Sweep.new(main_root, tasks, Git.base_branch(main_root, manifest)).items
2831
+ return puts("nothing to clean") if items.empty?
2832
+
2833
+ items.each { |item| puts sweep_line(item, apply) }
2834
+ apply ? Git.run(main_root, "worktree", "prune") : puts("Dry run. Run coord gc --yes to delete.")
2835
+ end
2836
+
2837
+ def sweep_line(item, apply)
2838
+ return "keep #{item.label}: #{item.reason}" if item.reason
2839
+ return "delete #{item.label}" unless apply
2840
+
2841
+ item.action.call ? "deleted #{item.label}" : "failed #{item.label}"
2842
+ end
2843
+
2844
+ private
2845
+
2846
+ # A worker started in a harness that has no file for its role runs
2847
+ # without its duties. Name each harness that lacks the role file. maf sets
2848
+ # MAF_HARNESS, because maf knows the harness that starts the worker.
2849
+ def warn_missing_role_files(role)
2850
+ harnesses = ENV["MAF_HARNESS"] ? [ENV["MAF_HARNESS"]] : manifest.harnesses
2851
+ missing = harnesses - manifest.harnesses_for(role)
2852
+ return if missing.empty?
2853
+
2854
+ warn "coord: #{role} has no role file for #{missing.join(", ")}. " \
2855
+ "Fix: maf add #{missing.map { |harness| "#{harness}:#{role}" }.join(" ")}"
2856
+ end
2857
+ end
2858
+
2859
+ # CommandHelpers holds the guards and lookups that many commands share.
2860
+ module CommandHelpers
2861
+ private
2862
+
2863
+ def manifest = @manifest ||= Manifest.new(paths.manifest_path)
2864
+ def main_root = File.dirname(File.dirname(File.expand_path(@coord_dir)))
2865
+
2866
+ def require_role!(value, usage) = require_set!(value, "role", "COORD_ROLE", usage)
2867
+ def require_worker!(value, usage) = require_set!(value, "worker", "COORD_WORKER", usage)
2868
+
2869
+ def require_set!(value, term, env_name, usage)
2870
+ return value unless value.nil? || value.empty? || value == "unknown"
2871
+
2872
+ abort "coord: no #{term} set. Pass one explicitly or `export #{env_name}=<#{term}>` (usage: #{usage})"
2873
+ end
2874
+
2875
+ LEAD_HINT = "coord: role %<role>s is a lead role and %<what>s. Wait for messages with coord inbox --wait."
2876
+
2877
+ def refuse_lead!(role, what)
2878
+ abort format(LEAD_HINT, role: role, what: what) if LEADS.include?(role)
2879
+ end
2880
+
2881
+ # Polls at `interval` seconds until `check` returns a non-empty result, then
2882
+ # runs the given block once. Matches the documented "wait 60s, do not spin"
2883
+ # rule in code instead of leaving it to agent self-discipline.
2884
+ def wait_for(interval, timeout, check)
2885
+ deadline = Time.now + timeout if timeout
2886
+ sleep interval while check.call.empty? && !timed_out!(deadline, timeout)
2887
+ yield
2888
+ end
2889
+
2890
+ def timed_out!(deadline, timeout)
2891
+ abort "coord: timed out after #{timeout}s waiting" if deadline && Time.now >= deadline
2892
+ false
2893
+ end
2894
+ end
2895
+
2896
+ # CLI parses arguments and dispatches to a command.
2897
+ class CLI
2898
+ include TaskCommands
2899
+ include ClaimCommands
2900
+ include MessageCommands
2901
+ include InboxCommands
2902
+ include GoalCommands
2903
+ include ReviewCommands
2904
+ include UpkeepCommands
2905
+ include CommandHelpers
2906
+ include Wiring
2907
+
2908
+ def initialize(argv, env: ENV)
2909
+ @args, @env, @project_root = Args.new(argv), env, Dir.pwd
2910
+ @coord_dir = env.fetch("COORD_DIR", File.join(MAF_DIR, "coordination"))
2911
+ @role = env.fetch("COORD_ROLE", "unknown")
2912
+ @worker = env.fetch("COORD_WORKER", @role)
2913
+ ENV["TASKRC"] = @taskrc = env.fetch("TASKRC", File.join(@coord_dir, "taskrc"))
2914
+ end
2915
+
2916
+ # Each commit of coord, as each commit of an agent, uses the git persona of the agents.
2917
+ def run
2918
+ Maf::Shared::GitIdentity.apply(Maf::Shared::GitIdentity.read(paths.manifest_path))
2919
+ presence.touch(@role, @worker, @env)
2920
+ dispatch(@args.shift || "help")
2921
+ rescue Error => e
2922
+ abort "coord: #{e.message}"
2923
+ end
2924
+
2925
+ private
2926
+
2927
+ def dispatch(command)
2928
+ method = COMMANDS[command]
2929
+ abort "coord: unknown command '#{command}'\n\n#{Usage::TEXT}" unless method
2930
+
2931
+ send(method)
2932
+ end
2933
+ end
2934
+ end
2935
+
2936
+ Coord::CLI.new(ARGV).run if __FILE__ == $PROGRAM_NAME