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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +411 -0
- data/assets/agents-contract.md +80 -0
- data/assets/analyst +240 -0
- data/assets/coord +2936 -0
- data/assets/dashboard +553 -0
- data/assets/dashboard.html +341 -0
- data/assets/dispatcher +1687 -0
- data/assets/doc-graph-refresh +286 -0
- data/assets/env.sh +6 -0
- data/assets/git-hooks/post-commit +7 -0
- data/assets/git-hooks/post-merge +7 -0
- data/assets/git-hooks/pre-commit +32 -0
- data/assets/harness-hooks/board-watch-opencode.js +87 -0
- data/assets/harness-hooks/board-watch.rb +286 -0
- data/assets/harness-hooks/context-watch.rb +268 -0
- data/assets/harness-hooks/next-task-hermes.sh +48 -0
- data/assets/harness-hooks/next-task.rb +97 -0
- data/assets/harness-hooks/session-guard.rb +128 -0
- data/assets/taskrc.append +11 -0
- data/assets/vault +224 -0
- data/assets/worktree-env.example.rb +26 -0
- data/exe/maf +14 -0
- data/install.md +326 -0
- data/lib/maf/bootstrap/claude_settings.rb +55 -0
- data/lib/maf/bootstrap/dependencies.rb +37 -0
- data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
- data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
- data/lib/maf/bootstrap/graph_home.rb +62 -0
- data/lib/maf/bootstrap/hook_merger.rb +53 -0
- data/lib/maf/bootstrap/installer.rb +66 -0
- data/lib/maf/bootstrap/layout_planner.rb +18 -0
- data/lib/maf/bootstrap/marked_block.rb +44 -0
- data/lib/maf/bootstrap/memory_branch.rb +77 -0
- data/lib/maf/bootstrap/options.rb +34 -0
- data/lib/maf/bootstrap/project.rb +77 -0
- data/lib/maf/bootstrap/script_planner.rb +81 -0
- data/lib/maf/bootstrap/text_planner.rb +42 -0
- data/lib/maf/bootstrap/vault_starter.rb +41 -0
- data/lib/maf/bootstrap/writer.rb +69 -0
- data/lib/maf/bootstrap.rb +162 -0
- data/lib/maf/budget.rb +59 -0
- data/lib/maf/cli.rb +135 -0
- data/lib/maf/env_exclude.rb +23 -0
- data/lib/maf/flow/agent_links.rb +79 -0
- data/lib/maf/flow/bootstrapper.rb +36 -0
- data/lib/maf/flow/codex_hooks.rb +50 -0
- data/lib/maf/flow/generator.rb +63 -0
- data/lib/maf/flow/harness_linker.rb +37 -0
- data/lib/maf/flow/hermes_hook.rb +48 -0
- data/lib/maf/flow/hermes_hook_setup.rb +69 -0
- data/lib/maf/flow/hook_files.rb +16 -0
- data/lib/maf/flow/hook_installer.rb +33 -0
- data/lib/maf/flow/legacy_codex_hook.rb +71 -0
- data/lib/maf/flow/manifest.rb +51 -0
- data/lib/maf/flow/mcp_config.rb +72 -0
- data/lib/maf/flow/mcp_installer.rb +45 -0
- data/lib/maf/flow/models.rb +61 -0
- data/lib/maf/flow/options.rb +65 -0
- data/lib/maf/flow/prompt_builder.rb +85 -0
- data/lib/maf/flow/prompt_text.rb +263 -0
- data/lib/maf/flow/report.rb +89 -0
- data/lib/maf/flow/role_catalog.rb +40 -0
- data/lib/maf/flow/role_files.rb +72 -0
- data/lib/maf/flow/role_stub.rb +38 -0
- data/lib/maf/flow/roster.rb +28 -0
- data/lib/maf/flow/validator.rb +38 -0
- data/lib/maf/flow/workflow.rb +28 -0
- data/lib/maf/flow.rb +84 -0
- data/lib/maf/local_exclude.rb +53 -0
- data/lib/maf/menu.rb +101 -0
- data/lib/maf/migrate/moves.rb +44 -0
- data/lib/maf/migrate/rewrites.rb +53 -0
- data/lib/maf/migrate/role_files.rb +35 -0
- data/lib/maf/migrate/runner.rb +66 -0
- data/lib/maf/migrate/worktrees.rb +65 -0
- data/lib/maf/migrate.rb +62 -0
- data/lib/maf/prompt.rb +40 -0
- data/lib/maf/retire.rb +116 -0
- data/lib/maf/role_limits.rb +49 -0
- data/lib/maf/setup_agent/args.rb +57 -0
- data/lib/maf/setup_agent/dispatch.rb +44 -0
- data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
- data/lib/maf/setup_agent/hermes_skill.rb +26 -0
- data/lib/maf/setup_agent/launcher.rb +85 -0
- data/lib/maf/setup_agent/manifest.rb +35 -0
- data/lib/maf/setup_agent/project.rb +9 -0
- data/lib/maf/setup_agent/role_file.rb +30 -0
- data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
- data/lib/maf/setup_agent/worktree.rb +50 -0
- data/lib/maf/setup_agent.rb +111 -0
- data/lib/maf/shared/git_exclude.rb +33 -0
- data/lib/maf/shared/git_identity.rb +41 -0
- data/lib/maf/shared/peak_rate.rb +20 -0
- data/lib/maf/shared/processes.rb +31 -0
- data/lib/maf/shared/project.rb +34 -0
- data/lib/maf/shared/roles.rb +19 -0
- data/lib/maf/team.rb +114 -0
- data/lib/maf/team_command.rb +73 -0
- data/lib/maf/uninstall/claude_settings.rb +40 -0
- data/lib/maf/uninstall/codex_hooks.rb +18 -0
- data/lib/maf/uninstall/commit_guard.rb +16 -0
- data/lib/maf/uninstall/coordination.rb +15 -0
- data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
- data/lib/maf/uninstall/git.rb +13 -0
- data/lib/maf/uninstall/local_files.rb +33 -0
- data/lib/maf/uninstall/manifest.rb +29 -0
- data/lib/maf/uninstall/marked_files.rb +37 -0
- data/lib/maf/uninstall/mcp_entries.rb +43 -0
- data/lib/maf/uninstall/notes.rb +31 -0
- data/lib/maf/uninstall/owned.rb +12 -0
- data/lib/maf/uninstall/role_files.rb +51 -0
- data/lib/maf/uninstall/runner.rb +67 -0
- data/lib/maf/uninstall/scripts.rb +35 -0
- data/lib/maf/uninstall/vault_watcher.rb +21 -0
- data/lib/maf/uninstall/worktrees.rb +30 -0
- data/lib/maf/uninstall.rb +59 -0
- data/lib/maf/untrack.rb +90 -0
- data/lib/maf/version.rb +5 -0
- data/lib/maf/worker_archive.rb +63 -0
- data/lib/maf/worker_control.rb +137 -0
- data/lib/maf/workers.rb +37 -0
- data/lib/maf.rb +5 -0
- data/templates/claude.md.erb +16 -0
- data/templates/codex.md.erb +7 -0
- data/templates/hermes.md.erb +12 -0
- data/templates/opencode.md.erb +24 -0
- data/templates/role-stub.yml.erb +15 -0
- data/templates/roles.yml +289 -0
- data/templates/workflows/panel.md +20 -0
- data/templates/workflows/plan-review.md +9 -0
- data/templates/workflows/simple.md +4 -0
- data/templates/workflows/tdd.md +8 -0
- 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
|
+
)
|