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
|
+
# doc-graph-refresh - rebuild the knowledge graph after a markdown change.
|
|
5
|
+
# Usage: .maf/bin/doc-graph-refresh post-commit|post-merge
|
|
6
|
+
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
abort "doc-graph-refresh: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
|
|
12
|
+
|
|
13
|
+
module DocGraph
|
|
14
|
+
# graphify names the output dir under --out "graphify-out". The graph lives
|
|
15
|
+
# in that dir at the project root, where graphify looks by default.
|
|
16
|
+
GRAPH = "graphify-out"
|
|
17
|
+
OBSIDIAN = File.join(GRAPH, "obsidian")
|
|
18
|
+
# The refresh never replaces these folders of the graph: memory/ holds the
|
|
19
|
+
# saved notes, obsidian/ holds the vault settings of the user.
|
|
20
|
+
KEPT = %w[memory obsidian].freeze
|
|
21
|
+
LOG = ".maf/coordination/doc-graph.log"
|
|
22
|
+
LOCK = ".maf/coordination/doc-graph.lock"
|
|
23
|
+
PENDING = ".maf/coordination/doc-graph.pending"
|
|
24
|
+
EVENTS = %w[post-commit post-merge].freeze
|
|
25
|
+
MARKDOWN = /\.md\z/i
|
|
26
|
+
|
|
27
|
+
# @param event [String] "post-commit" or "post-merge"
|
|
28
|
+
# @return [void]
|
|
29
|
+
def self.run(event)
|
|
30
|
+
return unless EVENTS.include?(event)
|
|
31
|
+
|
|
32
|
+
reason = skip_reason(event)
|
|
33
|
+
reason ? Log.write("#{event}: #{reason}") : refresh
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.skip_reason(event)
|
|
37
|
+
return "no markdown change" unless markdown?(event)
|
|
38
|
+
|
|
39
|
+
"GEMINI_API_KEY unset, skipped" if ENV["GEMINI_API_KEY"].to_s.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.markdown?(event)
|
|
43
|
+
Git.changed(event).any? { |file| file.match?(MARKDOWN) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.refresh
|
|
47
|
+
return Lock.mark_pending unless Lock.acquire
|
|
48
|
+
|
|
49
|
+
run_locked([toplevel])
|
|
50
|
+
recheck
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.run_locked(own)
|
|
54
|
+
drain(own)
|
|
55
|
+
ensure
|
|
56
|
+
Lock.release
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.recheck
|
|
60
|
+
return unless Lock.pending? && Lock.acquire
|
|
61
|
+
|
|
62
|
+
run_locked([])
|
|
63
|
+
recheck
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.drain(own)
|
|
67
|
+
trees = (own + Lock.take_pending).uniq
|
|
68
|
+
return if trees.empty?
|
|
69
|
+
|
|
70
|
+
trees.each { |tree| Refresh.run(tree) }
|
|
71
|
+
drain([])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.toplevel
|
|
75
|
+
path = Git.read("rev-parse", "--show-toplevel")
|
|
76
|
+
path.empty? ? Dir.pwd : path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.main
|
|
80
|
+
common = Git.read("rev-parse", "--git-common-dir")
|
|
81
|
+
common.empty? ? Dir.pwd : File.expand_path("..", common)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.graph_path = File.join(main, GRAPH)
|
|
85
|
+
|
|
86
|
+
module Git
|
|
87
|
+
def self.changed(event)
|
|
88
|
+
args = event == "post-merge" ? ["diff", "--name-only", "ORIG_HEAD", "HEAD"] : changed_commit
|
|
89
|
+
read(*args).lines.map(&:strip).reject(&:empty?)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.changed_commit
|
|
93
|
+
["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "-m", "HEAD"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.read(*args)
|
|
97
|
+
IO.popen(["git", *args], err: File::NULL, &:read).strip
|
|
98
|
+
rescue Errno::ENOENT
|
|
99
|
+
""
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
module Lock
|
|
104
|
+
TTL = 3600
|
|
105
|
+
|
|
106
|
+
def self.acquire
|
|
107
|
+
FileUtils.mkdir_p(File.dirname(path(LOCK)))
|
|
108
|
+
take || (break_stale && take)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.take
|
|
112
|
+
Dir.mkdir(path(LOCK))
|
|
113
|
+
File.write(pid_file, Process.pid.to_s)
|
|
114
|
+
true
|
|
115
|
+
rescue SystemCallError
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.break_stale
|
|
120
|
+
return false unless stale?
|
|
121
|
+
|
|
122
|
+
FileUtils.rm_rf(path(LOCK))
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.stale?
|
|
127
|
+
holder = holder_pid
|
|
128
|
+
return holder_dead?(holder) if holder
|
|
129
|
+
|
|
130
|
+
Time.now - File.mtime(path(LOCK)) > TTL
|
|
131
|
+
rescue SystemCallError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.holder_dead?(pid)
|
|
136
|
+
Process.kill(0, pid) && Time.now - File.mtime(path(LOCK)) > TTL
|
|
137
|
+
rescue SystemCallError => e
|
|
138
|
+
e.is_a?(Errno::ESRCH)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.holder_pid
|
|
142
|
+
Integer(File.read(pid_file).strip)
|
|
143
|
+
rescue SystemCallError, ArgumentError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.release
|
|
148
|
+
FileUtils.rm_rf(path(LOCK))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.mark_pending
|
|
152
|
+
FileUtils.mkdir_p(File.dirname(path(PENDING)))
|
|
153
|
+
File.open(path(PENDING), "a") { |io| io.puts(DocGraph.toplevel) }
|
|
154
|
+
Log.write("refresh already running; marked pending")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The rename takes the list atomically. A new pending mark starts a new list.
|
|
158
|
+
def self.take_pending
|
|
159
|
+
taken = "#{path(PENDING)}.#{Process.pid}"
|
|
160
|
+
File.rename(path(PENDING), taken)
|
|
161
|
+
File.readlines(taken, chomp: true).reject(&:empty?).tap { FileUtils.rm_f(taken) }
|
|
162
|
+
rescue Errno::ENOENT
|
|
163
|
+
[]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.pending? = File.exist?(path(PENDING))
|
|
167
|
+
def self.pid_file = File.join(path(LOCK), "pid")
|
|
168
|
+
def self.path(name) = File.join(DocGraph.main, name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
module Refresh
|
|
172
|
+
ATTEMPTS = 3
|
|
173
|
+
RETRY_WAIT = 30
|
|
174
|
+
|
|
175
|
+
def self.run(tree)
|
|
176
|
+
Dir.mktmpdir("doc-graph-refresh") { |tmp| seed_and_refresh(tmp, tree) }
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
Log.write("refresh failed: #{e.message}")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.seed_and_refresh(tmp, tree)
|
|
182
|
+
seed(tmp)
|
|
183
|
+
refresh(tmp, tree)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.seed(tmp)
|
|
187
|
+
graph = DocGraph.graph_path
|
|
188
|
+
return unless File.exist?(graph)
|
|
189
|
+
|
|
190
|
+
FileUtils.mkdir_p(File.join(tmp, GRAPH))
|
|
191
|
+
(Dir.children(graph) - KEPT).each { |name| FileUtils.cp_r(File.join(graph, name), File.join(tmp, GRAPH)) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.refresh(tmp, tree)
|
|
195
|
+
return Log.write("extract failed; kept the old graph") unless build(tmp, tree)
|
|
196
|
+
|
|
197
|
+
Swap.move(File.join(tmp, GRAPH), DocGraph.graph_path)
|
|
198
|
+
Reflect.run
|
|
199
|
+
Export.run
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.build(tmp, tree, attempt = 1)
|
|
203
|
+
return true if extract_ok?(tmp, tree)
|
|
204
|
+
return false if attempt >= ATTEMPTS
|
|
205
|
+
|
|
206
|
+
Log.write("extract attempt #{attempt} failed; retrying")
|
|
207
|
+
sleep(retry_wait)
|
|
208
|
+
build(tmp, tree, attempt + 1)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.retry_wait = Integer(ENV.fetch("DOC_GRAPH_RETRY_WAIT", RETRY_WAIT))
|
|
212
|
+
|
|
213
|
+
EXTRACT_FLAGS = %w[--backend gemini --max-concurrency 1].freeze
|
|
214
|
+
|
|
215
|
+
def self.extract_ok?(tmp, tree)
|
|
216
|
+
system({ "GRAPHIFY_OUT" => nil }, "graphify", "extract", tree, *EXTRACT_FLAGS, "--out", tmp) &&
|
|
217
|
+
File.exist?(File.join(tmp, GRAPH, "graph.json"))
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Swap replaces each derived entry of the graph folder on its own.
|
|
222
|
+
# The KEPT folders stay in place.
|
|
223
|
+
module Swap
|
|
224
|
+
def self.move(fresh, dest)
|
|
225
|
+
FileUtils.mkdir_p(dest)
|
|
226
|
+
(Dir.children(fresh) - KEPT).each { |name| replace(File.join(fresh, name), File.join(dest, name)) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def self.replace(fresh, dest)
|
|
230
|
+
old = "#{dest}.old"
|
|
231
|
+
FileUtils.rm_rf(old)
|
|
232
|
+
FileUtils.mv(dest, old) if File.exist?(dest)
|
|
233
|
+
install(fresh, dest, old)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.install(fresh, dest, old)
|
|
237
|
+
FileUtils.mv(fresh, dest)
|
|
238
|
+
FileUtils.rm_rf(old)
|
|
239
|
+
rescue SystemCallError
|
|
240
|
+
FileUtils.mv(old, dest) if File.exist?(old)
|
|
241
|
+
raise
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Reflect rebuilds reflections/LESSONS.md from the saved notes in memory/.
|
|
246
|
+
# With the new graph, reflect drops each lesson node that the graph lost.
|
|
247
|
+
module Reflect
|
|
248
|
+
def self.run
|
|
249
|
+
graph = DocGraph.graph_path
|
|
250
|
+
return unless File.directory?(File.join(graph, "memory"))
|
|
251
|
+
|
|
252
|
+
system({ "GRAPHIFY_OUT" => nil }, "graphify", "reflect", "--graph", File.join(graph, "graph.json"),
|
|
253
|
+
"--memory-dir", File.join(graph, "memory"), "--out", File.join(graph, "reflections", "LESSONS.md"))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Export writes the Obsidian vault to graphify-out/obsidian, the default
|
|
258
|
+
# folder of graphify. An agent that runs graphify export writes the same vault.
|
|
259
|
+
module Export
|
|
260
|
+
def self.run
|
|
261
|
+
graph_path = DocGraph.graph_path
|
|
262
|
+
command = ["graphify", "export", "obsidian", "--dir", OBSIDIAN]
|
|
263
|
+
Dir.chdir(DocGraph.main) { system({ "GRAPHIFY_OUT" => graph_path }, *command) }
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
module Log
|
|
268
|
+
def self.write(message)
|
|
269
|
+
File.open(path, "a") { |io| io.puts("#{Time.now.utc.iso8601} #{message}") }
|
|
270
|
+
rescue SystemCallError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def self.path
|
|
275
|
+
path = File.join(DocGraph.main, DocGraph::LOG)
|
|
276
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
277
|
+
path
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
begin
|
|
283
|
+
DocGraph.run(ARGV.first)
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
warn "doc-graph-refresh: #{e.message}"
|
|
286
|
+
end
|
data/assets/env.sh
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# env.sh - shell environment of the multi-agent flow.
|
|
2
|
+
# Usage: source .maf/env.sh
|
|
3
|
+
# The file puts .maf/bin on PATH. After that, a worker runs `coord`, not `./coord`.
|
|
4
|
+
MAF_BIN="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/bin"
|
|
5
|
+
export MAF_BIN
|
|
6
|
+
case ":$PATH:" in *":$MAF_BIN:"*) ;; *) export PATH="$MAF_BIN:$PATH" ;; esac
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# >>> multi-agent-flow >>>
|
|
2
|
+
# Doc-graph refresh: start it detached; the hook never fails a commit.
|
|
3
|
+
_doc_graph_main=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)
|
|
4
|
+
if [ -n "$_doc_graph_main" ] && [ -x "$_doc_graph_main/.maf/bin/doc-graph-refresh" ]; then
|
|
5
|
+
nohup "$_doc_graph_main/.maf/bin/doc-graph-refresh" post-commit >/dev/null 2>&1 &
|
|
6
|
+
fi
|
|
7
|
+
# <<< multi-agent-flow <<<
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# >>> multi-agent-flow >>>
|
|
2
|
+
# Doc-graph refresh: start it detached; the hook never fails a commit.
|
|
3
|
+
_doc_graph_main=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)
|
|
4
|
+
if [ -n "$_doc_graph_main" ] && [ -x "$_doc_graph_main/.maf/bin/doc-graph-refresh" ]; then
|
|
5
|
+
nohup "$_doc_graph_main/.maf/bin/doc-graph-refresh" post-merge >/dev/null 2>&1 &
|
|
6
|
+
fi
|
|
7
|
+
# <<< multi-agent-flow <<<
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# commit-guard - git pre-commit hook for the multi-agent flow.
|
|
5
|
+
#
|
|
6
|
+
# Refuses a commit when COORD_ROLE names a role with `can_edit: false` in
|
|
7
|
+
# .maf/config.json (project manager, architect, reviewer). A role file tells
|
|
8
|
+
# these roles not to edit code. This hook stops their edits from reaching a
|
|
9
|
+
# branch. `git merge` does not run pre-commit, so the architect can still
|
|
10
|
+
# merge task branches. A commit without COORD_ROLE (a human) always passes.
|
|
11
|
+
require "json"
|
|
12
|
+
|
|
13
|
+
module CommitGuard
|
|
14
|
+
MESSAGE = "commit-guard: role %s has can_edit false and cannot commit. " \
|
|
15
|
+
"Send the change to a worker role as a task."
|
|
16
|
+
|
|
17
|
+
# The main checkout holds the manifest. In a worktree, the common git dir
|
|
18
|
+
# is absolute. In the main checkout, it is relative to the checkout.
|
|
19
|
+
def self.root = File.dirname(File.expand_path(`git rev-parse --git-common-dir`.strip))
|
|
20
|
+
|
|
21
|
+
def self.agents
|
|
22
|
+
path = File.join(root, ".maf/config.json")
|
|
23
|
+
File.exist?(path) ? JSON.parse(File.read(path)).fetch("agents", []) : []
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.read_only?(role) = agents.any? { |agent| agent["role"] == role && agent["can_edit"] == false }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
role = ENV["COORD_ROLE"].to_s
|
|
32
|
+
abort format(CommitGuard::MESSAGE, role) if !role.empty? && CommitGuard.read_only?(role)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// board-watch-opencode.js - opencode plugin that wakes an idle session when the board has work.
|
|
2
|
+
//
|
|
3
|
+
// opencode loads this file from .opencode/plugins/board-watch.js. When a
|
|
4
|
+
// top-level session goes idle, the plugin checks the board at once and then
|
|
5
|
+
// every BOARD_WATCH_INTERVAL seconds (default 60). Each check runs
|
|
6
|
+
// .maf/coordination/harness-hooks/board-watch.rb --once. If the script exits 2,
|
|
7
|
+
// the plugin sends the script output to the session as a new prompt. When a
|
|
8
|
+
// session is busy again, the checks stop.
|
|
9
|
+
//
|
|
10
|
+
// board-watch.rb owns the board rules and the poke backoff.
|
|
11
|
+
// Required env: COORD_ROLE (set by maf start). COORD_DIR and TASKRC optional.
|
|
12
|
+
// The plugin does nothing if COORD_DISPATCHED is set: the dispatcher owns
|
|
13
|
+
// the loop for dispatched agents.
|
|
14
|
+
import { execFile } from "node:child_process"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
|
|
17
|
+
const WAKE = 2
|
|
18
|
+
|
|
19
|
+
const server = async ({ client, directory }) => {
|
|
20
|
+
const role = process.env.COORD_ROLE || ""
|
|
21
|
+
if (!role || role === "unknown" || !process.env.MAF_SESSION_TOKEN || process.env.COORD_DISPATCHED) return {}
|
|
22
|
+
|
|
23
|
+
const script = path.join(directory, ".maf", "coordination", "harness-hooks", "board-watch.rb")
|
|
24
|
+
// Same rule as board-watch.rb: a whole number of seconds, else 60.
|
|
25
|
+
const raw = (process.env.BOARD_WATCH_INTERVAL || "").trim()
|
|
26
|
+
const interval = (/^\d+$/.test(raw) ? Number(raw) : 60) * 1000
|
|
27
|
+
let watched = null
|
|
28
|
+
|
|
29
|
+
const log = (message) =>
|
|
30
|
+
client.app.log({ body: { service: "board-watch", level: "error", message } }).catch(() => {})
|
|
31
|
+
|
|
32
|
+
// Returns the poke prompt, or null when the board has no work that is due.
|
|
33
|
+
// If ruby or the script fails, the plugin logs the error.
|
|
34
|
+
const check = (id, event = "Stop") =>
|
|
35
|
+
new Promise((resolve) => {
|
|
36
|
+
const child = execFile("ruby", [script, "--once"], { cwd: directory }, (error, stdout, stderr) => {
|
|
37
|
+
if (error && error.code !== WAKE) log(`${script} --once failed: ${stderr.trim() || error.message}`)
|
|
38
|
+
resolve(error?.code === WAKE ? stdout.trim() : null)
|
|
39
|
+
})
|
|
40
|
+
child.stdin.on("error", () => {})
|
|
41
|
+
child.stdin.end(JSON.stringify({ session_id: id, hook_event_name: event, cwd: directory }))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const stop = () => {
|
|
45
|
+
clearTimeout(watched?.timer)
|
|
46
|
+
watched = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const poke = (id, text) =>
|
|
50
|
+
client.session
|
|
51
|
+
.promptAsync({ path: { id }, body: { agent: role, parts: [{ type: "text", text }] } })
|
|
52
|
+
.catch(() => {})
|
|
53
|
+
|
|
54
|
+
// Each watch gets a new entry. A check that ends after stop() or after a
|
|
55
|
+
// new watch sees a different entry and does nothing.
|
|
56
|
+
const tick = async (entry) => {
|
|
57
|
+
const prompt = await check(entry.id)
|
|
58
|
+
if (watched !== entry) return
|
|
59
|
+
if (!prompt) {
|
|
60
|
+
entry.timer = setTimeout(() => tick(entry), interval)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
stop()
|
|
64
|
+
await poke(entry.id, prompt)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A subagent session (for example explore) has a parent. Only the
|
|
68
|
+
// top-level session runs the work loop.
|
|
69
|
+
const watch = async (id) => {
|
|
70
|
+
const session = await client.session.get({ path: { id } }).catch(() => null)
|
|
71
|
+
if (!session?.data || session.data.parentID) return
|
|
72
|
+
await check(id, "SessionStart")
|
|
73
|
+
stop()
|
|
74
|
+
watched = { id, timer: null }
|
|
75
|
+
tick(watched)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
event: async ({ event }) => {
|
|
80
|
+
if (event.type === "session.idle") return watch(event.properties.sessionID)
|
|
81
|
+
if (event.type === "session.status" && event.properties.status.type !== "idle") return stop()
|
|
82
|
+
if (event.type === "session.deleted" && event.properties.info?.id === watched?.id) return stop()
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default { id: "multi-agent-flow-board-watch", server }
|