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
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # board-watch.rb - background board watcher for Claude Code sessions.
5
+ #
6
+ # Claude Code runs this script as an asyncRewake hook on SessionStart and on
7
+ # Stop. The hook process runs in the background. When the script exits with
8
+ # code 2, Claude Code shows its stderr to the model and starts a new turn,
9
+ # also in an idle session.
10
+ #
11
+ # Each interval, the script checks the session and the board:
12
+ # - If the session transcript changed in the last BOARD_WATCH_IDLE seconds,
13
+ # the agent is running. The script does nothing.
14
+ # - If the agent is idle and the board has work for the role, the script
15
+ # pokes the agent (stderr + exit 2) and ends.
16
+ # Work is: unclaimed tasks for the role, tasks that this worker claimed, and
17
+ # unread inbox messages. An unchanged poke repeats with a doubling delay.
18
+ #
19
+ # One watcher runs per worker (lock: .maf/coordination/locks/board-watch-<worker>.d).
20
+ # The script ends when its Claude Code process ends.
21
+ #
22
+ # Claude Code (.claude/settings.json), on SessionStart and on Stop:
23
+ # {"type":"command","command":"ruby .maf/coordination/harness-hooks/board-watch.rb",
24
+ # "async":true,"asyncRewake":true,"timeout":604800}
25
+ #
26
+ # Required env: COORD_ROLE (set by maf start). COORD_DIR and TASKRC optional.
27
+ # Env: BOARD_WATCH_INTERVAL (default 60), BOARD_WATCH_IDLE (default 120),
28
+ # BOARD_WATCH_BATCH (default 120).
29
+ # The script does nothing if COORD_DISPATCHED is set: the dispatcher owns
30
+ # the loop for dispatched agents.
31
+ #
32
+ # With --once, the script checks the board one time and does not wait. If
33
+ # the board has work that is due, the script prints the poke to stdout and
34
+ # exits 2. The opencode plugin (board-watch-opencode.js) uses this mode.
35
+ require "digest"
36
+ require "fileutils"
37
+ require "json"
38
+ require "rbconfig"
39
+ require_relative "session-guard"
40
+
41
+ module BoardWatch
42
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
43
+ MAX_BACKOFF = 3600
44
+ PROMPT = "Board watcher: the task board has work for role %<role>s.\n%<summary>s\n" \
45
+ "Run coord inbox, then coord next --mine, then coord next. " \
46
+ "Finish claimed tasks first. Claim the next task and complete it. When no work remains, stop."
47
+
48
+ # Work is one snapshot of the board for one worker.
49
+ Work = Struct.new(:unclaimed, :claimed, :messages) do
50
+ def any? = to_a.any? { |list| !list.empty? }
51
+ def digest = Digest::SHA256.hexdigest(to_a.inspect)
52
+
53
+ def summary
54
+ { "unclaimed tasks" => unclaimed, "your claimed tasks" => claimed, "unread messages" => messages }
55
+ .reject { |_label, list| list.empty? }.map { |label, list| "- #{label}: #{list.size}" }.join("\n")
56
+ end
57
+ end
58
+
59
+ # Board reads work for one role and worker through the coord CLI.
60
+ class Board
61
+ def initialize(coord, env)
62
+ @coord = coord
63
+ @env = env
64
+ end
65
+
66
+ def work
67
+ Work.new(task_ids("next", @env["COORD_ROLE"]), task_ids("next", "--mine"), messages)
68
+ end
69
+
70
+ # Messages often come in a group: each done task sends one. A poke for
71
+ # messages only waits until the oldest message is BATCH seconds old, so
72
+ # one turn reads the whole group. Each turn sends the whole context again.
73
+ def settled?(work, batch)
74
+ !(work.unclaimed + work.claimed).empty? || oldest_message_age >= batch
75
+ end
76
+
77
+ private
78
+
79
+ def task_ids(*args)
80
+ output = IO.popen(@env, [RbConfig.ruby, @coord, *args], err: File::NULL, &:read).to_s
81
+ output.lines.map(&:strip).grep(UUID).map { |line| line[UUID] }
82
+ end
83
+
84
+ def messages = message_paths.map { |path| File.basename(path) }
85
+
86
+ # An FYI message (coord msg --fyi) wakes no one. NOTE: assets/coord writes the mark.
87
+ def message_paths
88
+ Dir.glob(File.join(@env["COORD_DIR"], "inbox", @env["COORD_ROLE"], "*.md")).reject { |path| path.include?(".fyi.") }
89
+ end
90
+ def oldest_message_age = message_paths.map { |path| Time.now - File.mtime(path) }.max.to_i
91
+ end
92
+
93
+ # Session tells if the agent is running: Claude Code appends to the
94
+ # transcript while a turn runs.
95
+ class Session
96
+ def initialize(transcript, idle)
97
+ @transcript = transcript.to_s
98
+ @idle = idle
99
+ end
100
+
101
+ def running?
102
+ File.exist?(@transcript) && Time.now - File.mtime(@transcript) < @idle
103
+ end
104
+ end
105
+
106
+ # Owner is the Claude Code process that started the hook. A shell wrapper
107
+ # between the two is skipped.
108
+ class Owner
109
+ SHELLS = %w[sh bash zsh dash].freeze
110
+
111
+ def initialize(pid = Process.ppid)
112
+ @pid = SHELLS.include?(command(pid)) ? parent(pid) : pid
113
+ end
114
+
115
+ def alive?
116
+ @pid > 1 && Process.kill(0, @pid) && true
117
+ rescue Errno::ESRCH, Errno::EPERM
118
+ false
119
+ end
120
+
121
+ private
122
+
123
+ def command(pid) = File.basename(`ps -o comm= -p #{pid}`.strip)
124
+ def parent(pid) = `ps -o ppid= -p #{pid}`.strip.to_i
125
+ end
126
+
127
+ # Lock makes sure only one watcher runs per worker. A lock whose process
128
+ # is gone is stale and is taken over.
129
+ class Lock
130
+ def initialize(dir)
131
+ @dir = dir
132
+ @pid_file = File.join(dir, "pid")
133
+ end
134
+
135
+ def acquire
136
+ take || (stale? && FileUtils.rm_rf(@dir) && take)
137
+ end
138
+
139
+ def release
140
+ FileUtils.rm_rf(@dir) if File.exist?(@pid_file) && File.read(@pid_file).to_i == Process.pid
141
+ end
142
+
143
+ private
144
+
145
+ def take
146
+ FileUtils.mkdir_p(File.dirname(@dir))
147
+ Dir.mkdir(@dir) && File.write(@pid_file, Process.pid) && true
148
+ rescue Errno::EEXIST
149
+ false
150
+ end
151
+
152
+ def stale?
153
+ pid = File.exist?(@pid_file) ? File.read(@pid_file).to_i : 0
154
+ pid <= 0 || !Process.kill(0, pid)
155
+ rescue Errno::ESRCH
156
+ true
157
+ end
158
+ end
159
+
160
+ # Backoff stores the last poke of a worker. An unchanged poke repeats only
161
+ # after a delay that doubles each time, up to MAX_BACKOFF.
162
+ class Backoff
163
+ def initialize(path, interval)
164
+ @path = path
165
+ @interval = interval
166
+ end
167
+
168
+ def due?(work)
169
+ last = state
170
+ last["digest"] != work.digest || Time.now.to_i - last["at"].to_i >= last["delay"].to_i
171
+ end
172
+
173
+ def record(work)
174
+ last = state
175
+ delay = last["digest"] == work.digest ? [last["delay"].to_i * 2, @interval].max : @interval
176
+ FileUtils.mkdir_p(File.dirname(@path))
177
+ File.write(@path, JSON.generate(digest: work.digest, at: Time.now.to_i, delay: [delay, MAX_BACKOFF].min))
178
+ end
179
+
180
+ private
181
+
182
+ def state
183
+ File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
184
+ rescue JSON::ParserError
185
+ {}
186
+ end
187
+ end
188
+
189
+ Parts = Struct.new(:board, :session, :owner, :backoff, :batch, keyword_init: true)
190
+
191
+ # Watcher sleeps one interval per check. It returns the work to poke
192
+ # about, or nil if the owner process is gone.
193
+ class Watcher
194
+ def initialize(parts, interval)
195
+ @parts = parts
196
+ @interval = interval
197
+ end
198
+
199
+ def wait_for_work
200
+ work = nil
201
+ work = pokeable while work.nil? && @parts.owner.alive? && sleep(@interval)
202
+ work
203
+ end
204
+
205
+ private
206
+
207
+ def pokeable
208
+ return nil if @parts.session.running?
209
+
210
+ work = @parts.board.work
211
+ work if work.any? && @parts.board.settled?(work, @parts.batch) && @parts.backoff.due?(work)
212
+ end
213
+ end
214
+
215
+ # Main reads the environment and the hook input, runs one watcher, and
216
+ # returns the exit code: 2 to poke the agent, 0 otherwise.
217
+ class Main
218
+ def initialize(env, input)
219
+ @env = env
220
+ @input = input
221
+ end
222
+
223
+ def run
224
+ return 0 unless active? && lock.acquire
225
+
226
+ work = watcher.wait_for_work
227
+ lock.release
228
+ work ? poke(work) : 0
229
+ end
230
+
231
+ def run_once
232
+ return 0 unless active?
233
+ return 0 if @input["hook_event_name"] == "SessionStart"
234
+
235
+ board = Board.new(coord, board_env)
236
+ work = board.work
237
+ work.any? && board.settled?(work, batch) && backoff.due?(work) ? poke(work, $stdout) : 0
238
+ end
239
+
240
+ private
241
+
242
+ def active? = !@env["COORD_DISPATCHED"] && guard.authorized?
243
+ def role = @env["COORD_ROLE"].to_s
244
+ def worker = @env.fetch("COORD_WORKER", role)
245
+ def coord_dir = guard.coord_dir
246
+ def guard = @guard ||= MafSession::Guard.new(@env, @input)
247
+ def lock = @lock ||= Lock.new(File.join(coord_dir, "locks", "board-watch-#{worker}.d"))
248
+ def backoff = @backoff ||= Backoff.new(File.join(coord_dir, "sessions", "#{worker}.watch.json"), interval)
249
+ def interval = seconds("BOARD_WATCH_INTERVAL", 60)
250
+ def batch = seconds("BOARD_WATCH_BATCH", 120)
251
+ def seconds(name, default) = Integer(@env.fetch(name, default.to_s), exception: false) || default
252
+
253
+ def coord
254
+ guard.coord
255
+ end
256
+
257
+ def watcher
258
+ session = Session.new(@input["transcript_path"], seconds("BOARD_WATCH_IDLE", 120))
259
+ parts = Parts.new(board: Board.new(coord, board_env), session: session, owner: Owner.new, backoff: backoff,
260
+ batch: batch)
261
+ Watcher.new(parts, interval)
262
+ end
263
+
264
+ def board_env
265
+ @env.to_h.merge("COORD_DIR" => coord_dir, "COORD_ROLE" => role, "COORD_WORKER" => worker,
266
+ "TASKRC" => @env.fetch("TASKRC", File.join(coord_dir, "taskrc")))
267
+ end
268
+
269
+ def poke(work, out = $stderr)
270
+ backoff.record(work)
271
+ out.puts format(PROMPT, role: role, summary: work.summary)
272
+ 2
273
+ end
274
+ end
275
+ end
276
+
277
+ if __FILE__ == $PROGRAM_NAME
278
+ once = ARGV.include?("--once")
279
+ input = begin
280
+ JSON.parse($stdin.read.to_s)
281
+ rescue JSON::ParserError
282
+ {}
283
+ end
284
+ main = BoardWatch::Main.new(ENV, input)
285
+ exit once ? main.run_once : main.run
286
+ end
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # context-watch.rb - status, usage, and context limit hook for interactive
5
+ # Claude Code and Codex sessions.
6
+ #
7
+ # Stop: the script reads the new part of the session transcript. It writes
8
+ # the model and the context size to .maf/coordination/status/<worker>.json,
9
+ # and adds the token usage to .maf/coordination/usage/<worker>.json. If the
10
+ # context is over the limit, it asks the agent one time for a handoff note.
11
+ # Then it tells the user to type /clear. Each model call sends the whole
12
+ # context again, so a fresh session with the note costs less.
13
+ #
14
+ # SessionStart: after /clear or a new start, the script gives the new session
15
+ # the handoff note as context.
16
+ #
17
+ # Limit: MAF_CONTEXT_LIMIT, else team.context_limit in .maf/config.json, else
18
+ # DEFAULT_LIMIT tokens. Only a registered maf start session runs the script.
19
+ # Dispatched agents (COORD_DISPATCHED) are skipped: the dispatcher owns them.
20
+ require "json"
21
+ require "fileutils"
22
+ require "time"
23
+ require_relative "session-guard"
24
+
25
+ module ContextWatch
26
+ DEFAULT_LIMIT = 150_000
27
+ ASK = "Your context has %<tokens>d tokens, over the limit of %<limit>d. Each model call sends the whole " \
28
+ "context again. Overwrite %<path>s with a handoff note for your next session. Write at most 300 " \
29
+ "words: current state, decisions, open questions, and the next step. Then stop."
30
+ TELL = "Context: %<tokens>dk tokens (limit %<limit>dk). The handoff note is in %<path>s. " \
31
+ "Type /clear to restart. The new session loads the note."
32
+
33
+ # Reading is the new part of one transcript: model, context size, and usage.
34
+ Reading = Struct.new(:model, :context, :window, :usage, :offset, :total)
35
+
36
+ # Transcript reads the lines after OFFSET. Claude Code writes one line per
37
+ # content block, with the same message id and usage, so ids are counted once.
38
+ # Codex writes cumulative totals, so the usage is the change of the total.
39
+ class Transcript
40
+ USAGE_KEYS = %w[input_tokens cached_input_tokens cache_write_input_tokens output_tokens].freeze
41
+ CLAUDE_KEYS = { "cached_input_tokens" => "cache_read_input_tokens",
42
+ "cache_write_input_tokens" => "cache_creation_input_tokens", "output_tokens" => "output_tokens" }.freeze
43
+
44
+ def initialize(path, offset, seen_total)
45
+ @path, @offset, @seen_total = path, offset, seen_total
46
+ end
47
+
48
+ def read
49
+ reading = Reading.new(nil, nil, nil, Hash.new(0), @offset, nil)
50
+ lines { |event| claude(reading, event) || codex(reading, event) }
51
+ reading.offset = File.size(@path)
52
+ reading
53
+ end
54
+
55
+ private
56
+
57
+ def lines
58
+ File.open(@path) do |file|
59
+ file.seek(@offset)
60
+ file.each_line { |line| (event = parse(line)) && yield(event) }
61
+ end
62
+ end
63
+
64
+ def parse(line)
65
+ event = JSON.parse(line)
66
+ event.is_a?(Hash) ? event : nil
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+
71
+ def claude(reading, event)
72
+ usage = event.dig("message", "usage")
73
+ return false unless event["type"] == "assistant" && usage && !event["isSidechain"]
74
+
75
+ reading.model = event.dig("message", "model")
76
+ reading.context = %w[input_tokens cache_read_input_tokens cache_creation_input_tokens].sum { |k| usage[k].to_i }
77
+ add_claude(reading, event.dig("message", "id"), usage)
78
+ end
79
+
80
+ def add_claude(reading, id, usage)
81
+ return true if (@ids ||= {}).key?(id)
82
+
83
+ @ids[id] = true
84
+ reading.usage["input_tokens"] += reading.context
85
+ CLAUDE_KEYS.each { |key, name| reading.usage[key] += usage[name].to_i }
86
+ end
87
+
88
+ def codex(reading, event)
89
+ payload = event["payload"] || {}
90
+ reading.model = payload["model"] if event["type"] == "turn_context"
91
+ info = payload["info"] if payload["type"] == "token_count"
92
+ info && codex_tokens(reading, info)
93
+ end
94
+
95
+ def codex_tokens(reading, info)
96
+ reading.context = info.dig("last_token_usage", "input_tokens")
97
+ reading.window = info["model_context_window"]
98
+ reading.total = (info["total_token_usage"] || {}).slice(*USAGE_KEYS)
99
+ USAGE_KEYS.each { |key| reading.usage[key] = reading.total[key].to_i - @seen_total[key].to_i }
100
+ end
101
+ end
102
+
103
+ # Store reads and writes the status and the usage files of one worker.
104
+ class Store
105
+ def initialize(coord_dir, worker)
106
+ @status = File.join(coord_dir, "status", "#{worker}.json")
107
+ @usage = File.join(coord_dir, "usage", "#{worker}.json")
108
+ @handoff = File.join(coord_dir, "sessions", "#{worker}.handoff.md")
109
+ end
110
+
111
+ attr_reader :handoff
112
+
113
+ def status = read(@status)
114
+ def save(data) = write(@status, status.merge(data))
115
+ def note = File.exist?(@handoff) ? File.read(@handoff).strip : ""
116
+
117
+ def add_usage(delta)
118
+ return if delta.values.all?(&:zero?)
119
+
120
+ totals = read(@usage)
121
+ sums = delta.to_h { |key, value| [key, totals[key].to_i + value] }
122
+ write(@usage, totals.merge(sums).merge("runs" => totals["runs"].to_i + 1))
123
+ end
124
+
125
+ private
126
+
127
+ def read(path)
128
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
129
+ rescue JSON::ParserError
130
+ {}
131
+ end
132
+
133
+ def write(path, data)
134
+ FileUtils.mkdir_p(File.dirname(path))
135
+ File.write("#{path}.tmp", JSON.generate(data))
136
+ File.rename("#{path}.tmp", path)
137
+ end
138
+ end
139
+
140
+ # Hook handles one hook event and returns the JSON output, or nil.
141
+ class Hook
142
+ def initialize(input, env)
143
+ @input, @env = input, env
144
+ @store = Store.new(env.fetch("COORD_DIR"), env.fetch("COORD_WORKER"))
145
+ end
146
+
147
+ def run = @input["hook_event_name"] == "SessionStart" ? session_start : stop
148
+
149
+ private
150
+
151
+ # maf start gives the role file in the first prompt, not as a system
152
+ # prompt. /clear drops it, so a cleared session gets the role file again.
153
+ # The hook puts the file text in the context: a Read costs one model call.
154
+ ROLE_FILES = { "claude" => ".maf/agents/claude/%s.md", "codex" => ".codex/prompts/%s.md" }.freeze
155
+
156
+ def session_start
157
+ @store.save("session_id" => @input["session_id"], "asked" => nil)
158
+ context = [role_line, note_text].compact.join("\n\n")
159
+ context.empty? ? nil : { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }
160
+ end
161
+
162
+ def role_line
163
+ return nil unless @input["source"] == "clear"
164
+
165
+ file = role_file
166
+ intro = "You are worker #{@env["COORD_WORKER"]}."
167
+ return "#{intro} Read #{file} and follow it exactly. Start your work loop now." unless File.exist?(file)
168
+
169
+ "#{intro} Follow your role file #{file} exactly. Start your work loop now.\n\n#{File.read(file)}"
170
+ end
171
+
172
+ # The harness guess comes first. The file that exists in the worktree wins.
173
+ def role_file
174
+ names = ROLE_FILES.keys.sort_by { |name| name == harness ? 0 : 1 }
175
+ paths = names.map { |name| format(ROLE_FILES[name], @env["COORD_ROLE"]) }
176
+ paths.find { |file| File.exist?(file) } || paths.first
177
+ end
178
+
179
+ def note_text
180
+ note = @store.note
181
+ return nil if note.empty?
182
+
183
+ "Handoff note from your previous session (#{File.mtime(@store.handoff).utc.iso8601}):\n#{note}"
184
+ end
185
+
186
+ def stop
187
+ path = @input["transcript_path"].to_s
188
+ return nil unless File.exist?(path)
189
+
190
+ record(path, read_transcript(path))
191
+ limit_output(@store.status["context_tokens"].to_i)
192
+ end
193
+
194
+ # A new transcript (after /clear) is read from its start.
195
+ def read_transcript(path)
196
+ status = @store.status
197
+ return Transcript.new(path, 0, {}).read unless status["transcript"] == path
198
+
199
+ Transcript.new(path, status["offset"].to_i, status["seen_total"] || {}).read
200
+ end
201
+
202
+ def record(path, reading)
203
+ @store.add_usage(reading.usage)
204
+ @store.save(status_fields(path, reading))
205
+ end
206
+
207
+ # A value that this read did not find keeps its last known value.
208
+ def status_fields(path, reading)
209
+ found = { "model" => reading.model, "context_tokens" => reading.context, "context_window" => reading.window,
210
+ "seen_total" => reading.total }.compact
211
+ { "mode" => "interactive", "role" => @env["COORD_ROLE"], "harness" => harness, "context_limit" => limit,
212
+ "pid" => @env["COORD_SESSION_PID"]&.to_i, "transcript" => path, "offset" => reading.offset,
213
+ "updated_at" => Time.now.utc.iso8601 }.merge(found)
214
+ end
215
+
216
+ # Codex keeps its transcripts in ~/.codex/sessions.
217
+ def harness = @input["transcript_path"].to_s.include?("/.codex/") ? "codex" : "claude"
218
+
219
+ def limit_output(tokens)
220
+ return nil if tokens < limit
221
+ return { systemMessage: format(TELL, tokens: tokens / 1000, limit: limit / 1000, path: @store.handoff) } if asked?
222
+
223
+ @store.save("asked" => @input["session_id"])
224
+ { decision: "block", reason: format(ASK, tokens: tokens, limit: limit, path: @store.handoff) }
225
+ end
226
+
227
+ def asked? = @store.status["asked"] == @input["session_id"]
228
+
229
+ def limit
230
+ value = @env["MAF_CONTEXT_LIMIT"] || manifest_limit || DEFAULT_LIMIT
231
+ @limit ||= Integer(value, exception: false) || DEFAULT_LIMIT
232
+ end
233
+
234
+ def manifest_limit
235
+ JSON.parse(File.read(File.join(@env.fetch("COORD_DIR"), "..", "config.json"))).dig("team", "context_limit")
236
+ rescue Errno::ENOENT, JSON::ParserError
237
+ nil
238
+ end
239
+ end
240
+ end
241
+
242
+ module ContextWatch
243
+ # A failed hook shows an error in each turn of the session. The watch is
244
+ # optional, so an error is logged and the session goes on.
245
+ def self.main(input, env)
246
+ Hook.new(input, env).run if active?(input, env)
247
+ rescue StandardError => e
248
+ warn "context-watch: #{e.class}: #{e.message}"
249
+ nil
250
+ end
251
+
252
+ # Only a registered maf start session runs the hook. The dispatcher owns dispatched agents.
253
+ def self.active?(input, env)
254
+ return false if !input.is_a?(Hash) || env["COORD_DISPATCHED"] || !env["COORD_DIR"] || !env["COORD_WORKER"]
255
+
256
+ MafSession::Guard.new(env, input).authorized?
257
+ end
258
+ end
259
+
260
+ if $PROGRAM_NAME == __FILE__
261
+ input = begin
262
+ JSON.parse($stdin.read)
263
+ rescue JSON::ParserError
264
+ {}
265
+ end
266
+ output = ContextWatch.main(input, ENV)
267
+ $stdout.print(JSON.generate(output)) if output
268
+ end
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # next-task-hermes.sh - on_session_end hook for Hermes Agent.
5
+ # Only a registered MAF session can resume the work loop.
6
+ require "json"
7
+ require "rbconfig"
8
+ require_relative "session-guard"
9
+
10
+ module HermesNextTask
11
+ def self.run(input)
12
+ return unless input.is_a?(Hash)
13
+
14
+ guard = MafSession::Guard.new(ENV, input.merge("hook_event_name" => "SessionStart"))
15
+ resume(guard, input) if !ENV["COORD_DISPATCHED"] && guard.authorized? && work?(guard)
16
+ end
17
+
18
+ def self.work?(guard)
19
+ output = IO.popen(ENV.to_h, [RbConfig.ruby, guard.coord, "next", ENV.fetch("COORD_ROLE")],
20
+ err: File::NULL, &:read)
21
+ output.match?(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)
22
+ end
23
+
24
+ def self.resume(guard, input)
25
+ Process.detach(fork { start(guard, input) })
26
+ end
27
+
28
+ def self.start(guard, input)
29
+ Process.setsid
30
+ MafSession.register(File.dirname(File.dirname(guard.coord_dir)), "hermes")
31
+ exec({ "HERMES_ACCEPT_HOOKS" => "1" }, *command(input), in: File::NULL, out: File::NULL, err: File::NULL)
32
+ end
33
+
34
+ def self.command(input)
35
+ ["hermes", "chat", "--oneshot", "--yolo", "--accept-hooks", "--resume", input.fetch("session_id"), "-q", prompt]
36
+ end
37
+
38
+ def self.prompt
39
+ "Unclaimed tasks exist for role #{ENV.fetch('COORD_ROLE')}. Run coord inbox, then coord next. " \
40
+ "Claim and complete the next task. When no tasks remain, stop."
41
+ end
42
+ end
43
+
44
+ begin
45
+ HermesNextTask.run(JSON.parse($stdin.read))
46
+ rescue JSON::ParserError
47
+ exit 0
48
+ end
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # next-task.rb - Stop hook for Claude Code and Codex.
5
+ #
6
+ # Fires each time the supervised agent stops. If unclaimed tasks exist for the
7
+ # agent's role, outputs a JSON block decision that injects a work prompt so the
8
+ # agent continues without a human turn. Exits 0 with no output when no tasks
9
+ # are available, letting the session end normally.
10
+ #
11
+ # After `coord await`, the hook first waits for work: see AwaitWork.
12
+ #
13
+ # Claude Code (.claude/settings.json):
14
+ # {"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command",
15
+ # "command":"ruby .maf/coordination/harness-hooks/next-task.rb"}]}]}}
16
+ # Codex uses the project .codex/hooks.json file.
17
+ #
18
+ # Only a registered maf start session can read the board.
19
+ require "json"
20
+ require "rbconfig"
21
+ require_relative "session-guard"
22
+
23
+ UUID_LINE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
24
+
25
+ def unclaimed_tasks?(guard, role)
26
+ IO.popen(ENV.to_h, [RbConfig.ruby, guard.coord, "next", role], err: File::NULL, &:read).to_s.match?(UUID_LINE)
27
+ end
28
+
29
+ # AwaitWork serves `coord await`. The agent arms the hook and ends its turn.
30
+ # The hook then waits here, outside the model, until a waking message or a
31
+ # task arrives, or the wait ends. Codex has no watcher that wakes an idle
32
+ # session, and a wait inside a tool call returns to the model about every
33
+ # 30 seconds. This wait costs one model call at the end.
34
+ # NOTE: assets/coord writes the arm file and the FYI name mark.
35
+ class AwaitWork
36
+ LEADS = %w[project-manager architect].freeze
37
+ # Stay below the hook timeout that flow.rb sets for Codex (3600 seconds).
38
+ MAX_SECONDS = 3300
39
+ WOKE = "Work arrived for role %<role>s. Run coord inbox %<role>s. Then run coord next."
40
+ QUIET = "No work arrived for role %<role>s in %<minutes>d minutes. Tell the user. " \
41
+ "To wait again, run coord await and end your turn."
42
+
43
+ def initialize(guard, role, worker)
44
+ @guard, @role = guard, role
45
+ @path = File.join(guard.coord_dir, "locks", "await-#{worker}.json")
46
+ end
47
+
48
+ def armed? = File.exist?(@path)
49
+
50
+ def wait
51
+ started, deadline = Time.now, disarm
52
+ sleep tick until work? || Time.now >= deadline
53
+ format(work? ? WOKE : QUIET, role: @role, minutes: ((Time.now - started) / 60).round)
54
+ end
55
+
56
+ private
57
+
58
+ def disarm
59
+ limit = JSON.parse(File.read(@path)).fetch("until", 0).to_i
60
+ File.delete(@path)
61
+ Time.at([limit, Time.now.to_i + MAX_SECONDS].min)
62
+ rescue JSON::ParserError, SystemCallError
63
+ Time.now
64
+ end
65
+
66
+ def tick = ENV.fetch("MAF_AWAIT_TICK", "10").to_f
67
+ def work? = messages? || (!LEADS.include?(@role) && unclaimed_tasks?(@guard, @role))
68
+
69
+ def messages?
70
+ Dir.glob(File.join(@guard.coord_dir, "inbox", @role, "*.md")).any? { |path| !path.include?(".fyi.") }
71
+ end
72
+ end
73
+
74
+ input = begin
75
+ JSON.parse($stdin.read)
76
+ rescue JSON::ParserError
77
+ {}
78
+ end
79
+ exit 0 unless input.is_a?(Hash)
80
+ guard = MafSession::Guard.new(ENV, input)
81
+ exit 0 unless guard.authorized?
82
+ exit 0 if input["hook_event_name"] == "SessionStart" || ENV["COORD_DISPATCHED"]
83
+
84
+ role = ENV.fetch("COORD_ROLE")
85
+ await = AwaitWork.new(guard, role, ENV.fetch("COORD_WORKER", role))
86
+ if await.armed?
87
+ $stdout.print JSON.generate(decision: "block", reason: await.wait)
88
+ exit 0
89
+ end
90
+ exit 0 unless unclaimed_tasks?(guard, role)
91
+
92
+ $stdout.print JSON.generate(
93
+ decision: "block",
94
+ reason: "Unclaimed tasks exist for role #{role}. " \
95
+ "Run coord inbox to read messages, then coord next to list tasks. " \
96
+ "Claim the next task and complete it. When no tasks remain, stop."
97
+ )