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
+ # 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 }