maf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +411 -0
  5. data/assets/agents-contract.md +80 -0
  6. data/assets/analyst +240 -0
  7. data/assets/coord +2936 -0
  8. data/assets/dashboard +553 -0
  9. data/assets/dashboard.html +341 -0
  10. data/assets/dispatcher +1687 -0
  11. data/assets/doc-graph-refresh +286 -0
  12. data/assets/env.sh +6 -0
  13. data/assets/git-hooks/post-commit +7 -0
  14. data/assets/git-hooks/post-merge +7 -0
  15. data/assets/git-hooks/pre-commit +32 -0
  16. data/assets/harness-hooks/board-watch-opencode.js +87 -0
  17. data/assets/harness-hooks/board-watch.rb +286 -0
  18. data/assets/harness-hooks/context-watch.rb +268 -0
  19. data/assets/harness-hooks/next-task-hermes.sh +48 -0
  20. data/assets/harness-hooks/next-task.rb +97 -0
  21. data/assets/harness-hooks/session-guard.rb +128 -0
  22. data/assets/taskrc.append +11 -0
  23. data/assets/vault +224 -0
  24. data/assets/worktree-env.example.rb +26 -0
  25. data/exe/maf +14 -0
  26. data/install.md +326 -0
  27. data/lib/maf/bootstrap/claude_settings.rb +55 -0
  28. data/lib/maf/bootstrap/dependencies.rb +37 -0
  29. data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
  30. data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
  31. data/lib/maf/bootstrap/graph_home.rb +62 -0
  32. data/lib/maf/bootstrap/hook_merger.rb +53 -0
  33. data/lib/maf/bootstrap/installer.rb +66 -0
  34. data/lib/maf/bootstrap/layout_planner.rb +18 -0
  35. data/lib/maf/bootstrap/marked_block.rb +44 -0
  36. data/lib/maf/bootstrap/memory_branch.rb +77 -0
  37. data/lib/maf/bootstrap/options.rb +34 -0
  38. data/lib/maf/bootstrap/project.rb +77 -0
  39. data/lib/maf/bootstrap/script_planner.rb +81 -0
  40. data/lib/maf/bootstrap/text_planner.rb +42 -0
  41. data/lib/maf/bootstrap/vault_starter.rb +41 -0
  42. data/lib/maf/bootstrap/writer.rb +69 -0
  43. data/lib/maf/bootstrap.rb +162 -0
  44. data/lib/maf/budget.rb +59 -0
  45. data/lib/maf/cli.rb +135 -0
  46. data/lib/maf/env_exclude.rb +23 -0
  47. data/lib/maf/flow/agent_links.rb +79 -0
  48. data/lib/maf/flow/bootstrapper.rb +36 -0
  49. data/lib/maf/flow/codex_hooks.rb +50 -0
  50. data/lib/maf/flow/generator.rb +63 -0
  51. data/lib/maf/flow/harness_linker.rb +37 -0
  52. data/lib/maf/flow/hermes_hook.rb +48 -0
  53. data/lib/maf/flow/hermes_hook_setup.rb +69 -0
  54. data/lib/maf/flow/hook_files.rb +16 -0
  55. data/lib/maf/flow/hook_installer.rb +33 -0
  56. data/lib/maf/flow/legacy_codex_hook.rb +71 -0
  57. data/lib/maf/flow/manifest.rb +51 -0
  58. data/lib/maf/flow/mcp_config.rb +72 -0
  59. data/lib/maf/flow/mcp_installer.rb +45 -0
  60. data/lib/maf/flow/models.rb +61 -0
  61. data/lib/maf/flow/options.rb +65 -0
  62. data/lib/maf/flow/prompt_builder.rb +85 -0
  63. data/lib/maf/flow/prompt_text.rb +263 -0
  64. data/lib/maf/flow/report.rb +89 -0
  65. data/lib/maf/flow/role_catalog.rb +40 -0
  66. data/lib/maf/flow/role_files.rb +72 -0
  67. data/lib/maf/flow/role_stub.rb +38 -0
  68. data/lib/maf/flow/roster.rb +28 -0
  69. data/lib/maf/flow/validator.rb +38 -0
  70. data/lib/maf/flow/workflow.rb +28 -0
  71. data/lib/maf/flow.rb +84 -0
  72. data/lib/maf/local_exclude.rb +53 -0
  73. data/lib/maf/menu.rb +101 -0
  74. data/lib/maf/migrate/moves.rb +44 -0
  75. data/lib/maf/migrate/rewrites.rb +53 -0
  76. data/lib/maf/migrate/role_files.rb +35 -0
  77. data/lib/maf/migrate/runner.rb +66 -0
  78. data/lib/maf/migrate/worktrees.rb +65 -0
  79. data/lib/maf/migrate.rb +62 -0
  80. data/lib/maf/prompt.rb +40 -0
  81. data/lib/maf/retire.rb +116 -0
  82. data/lib/maf/role_limits.rb +49 -0
  83. data/lib/maf/setup_agent/args.rb +57 -0
  84. data/lib/maf/setup_agent/dispatch.rb +44 -0
  85. data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
  86. data/lib/maf/setup_agent/hermes_skill.rb +26 -0
  87. data/lib/maf/setup_agent/launcher.rb +85 -0
  88. data/lib/maf/setup_agent/manifest.rb +35 -0
  89. data/lib/maf/setup_agent/project.rb +9 -0
  90. data/lib/maf/setup_agent/role_file.rb +30 -0
  91. data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
  92. data/lib/maf/setup_agent/worktree.rb +50 -0
  93. data/lib/maf/setup_agent.rb +111 -0
  94. data/lib/maf/shared/git_exclude.rb +33 -0
  95. data/lib/maf/shared/git_identity.rb +41 -0
  96. data/lib/maf/shared/peak_rate.rb +20 -0
  97. data/lib/maf/shared/processes.rb +31 -0
  98. data/lib/maf/shared/project.rb +34 -0
  99. data/lib/maf/shared/roles.rb +19 -0
  100. data/lib/maf/team.rb +114 -0
  101. data/lib/maf/team_command.rb +73 -0
  102. data/lib/maf/uninstall/claude_settings.rb +40 -0
  103. data/lib/maf/uninstall/codex_hooks.rb +18 -0
  104. data/lib/maf/uninstall/commit_guard.rb +16 -0
  105. data/lib/maf/uninstall/coordination.rb +15 -0
  106. data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
  107. data/lib/maf/uninstall/git.rb +13 -0
  108. data/lib/maf/uninstall/local_files.rb +33 -0
  109. data/lib/maf/uninstall/manifest.rb +29 -0
  110. data/lib/maf/uninstall/marked_files.rb +37 -0
  111. data/lib/maf/uninstall/mcp_entries.rb +43 -0
  112. data/lib/maf/uninstall/notes.rb +31 -0
  113. data/lib/maf/uninstall/owned.rb +12 -0
  114. data/lib/maf/uninstall/role_files.rb +51 -0
  115. data/lib/maf/uninstall/runner.rb +67 -0
  116. data/lib/maf/uninstall/scripts.rb +35 -0
  117. data/lib/maf/uninstall/vault_watcher.rb +21 -0
  118. data/lib/maf/uninstall/worktrees.rb +30 -0
  119. data/lib/maf/uninstall.rb +59 -0
  120. data/lib/maf/untrack.rb +90 -0
  121. data/lib/maf/version.rb +5 -0
  122. data/lib/maf/worker_archive.rb +63 -0
  123. data/lib/maf/worker_control.rb +137 -0
  124. data/lib/maf/workers.rb +37 -0
  125. data/lib/maf.rb +5 -0
  126. data/templates/claude.md.erb +16 -0
  127. data/templates/codex.md.erb +7 -0
  128. data/templates/hermes.md.erb +12 -0
  129. data/templates/opencode.md.erb +24 -0
  130. data/templates/role-stub.yml.erb +15 -0
  131. data/templates/roles.yml +289 -0
  132. data/templates/workflows/panel.md +20 -0
  133. data/templates/workflows/plan-review.md +9 -0
  134. data/templates/workflows/simple.md +4 -0
  135. data/templates/workflows/tdd.md +8 -0
  136. metadata +193 -0
data/assets/analyst ADDED
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # analyst - ask a small model for token hints about one dispatched worker.
5
+ #
6
+ # Usage: analyst WORKER [--coord DIR] [--print]
7
+ #
8
+ # The analyst builds a short digest of the worker: the run history that the
9
+ # dispatcher writes, and the tool calls of the current session (counts by
10
+ # kind, output sizes, the largest outputs). A small model reads the digest
11
+ # and returns at most 3 hints. The analyst writes them to
12
+ # .maf/coordination/hints/<worker>.json, and the dashboard shows them.
13
+ #
14
+ # The default model call is Claude Haiku without tools, skills, MCP servers,
15
+ # user settings, or thinking: about 1.2k input and 200 output tokens, $0.002. Set another command in
16
+ # .maf/config.json: "team": { "analyst": { "command": ["opencode", "run"] } }.
17
+ # The analyst sends the prompt on stdin. --print shows the prompt and calls
18
+ # no model. The analyst runs only when the user asks (dashboard button).
19
+
20
+ require "json"
21
+ require "fileutils"
22
+ require "open3"
23
+ require "optparse"
24
+ require "tmpdir"
25
+ require "time"
26
+
27
+ module Analyst
28
+ HISTORY = 20
29
+ TOP = 5
30
+ # The short system prompt replaces the 7k-token default of Claude Code.
31
+ # MAX_THINKING_TOKENS=0 turns thinking off: 3k fewer output tokens.
32
+ DEFAULT_COMMAND = ["claude", "-p", "--model", "haiku", "--system-prompt", "Reply with JSON only.",
33
+ "--tools", "", "--strict-mcp-config", "--disable-slash-commands", "--setting-sources", "",
34
+ "--no-session-persistence", "--output-format", "json"].freeze
35
+ MODEL_ENV = { "MAX_THINKING_TOKENS" => "0" }.freeze
36
+ LIMITS = %w[max_context max_session_runs cache_window].freeze
37
+ KINDS = { "graphify" => /\bgraphify\b/, "grep" => /\b(rg|grep|ag)\s/, "read" => /\b(cat|sed -n|nl|head|tail)\s/,
38
+ "test" => %r{\b(rails test|rspec|bin/rails|rake)\b}, "git" => /\bgit\s/, "coord" => /\bcoord\s/ }.freeze
39
+
40
+ PROMPT = <<~TEXT
41
+ You review the token use of one AI coding worker. The worker runs as one-shot runs.
42
+ A dispatcher resumes the last session while the prompt cache is warm.
43
+ Each resume sends the whole old context again on every model call.
44
+ The session limits are: max_context (start fresh when the last call had this many context tokens),
45
+ max_session_runs (start fresh after this many runs), cache_window (seconds to resume a session).
46
+ In "runs", session_run 1 is a fresh session. "context" is the context of the last model call.
47
+ In "tools", each kind counts tool calls. graphify is a code graph query. It is cheaper than grep and file reads.
48
+
49
+ Find at most 3 problems that cost tokens. Name the numbers that show each problem.
50
+ For each problem, write one hint of at most 40 words.
51
+ Add "limits" only when a restart with that limit fixes that problem. Use an integer.
52
+ Never put the same limit in two hints.
53
+ A tool choice problem (many grep calls or file reads, few graphify calls) needs a prompt change, not a limit.
54
+ For a prompt or config change, write the change in the hint and add no limits.
55
+ Do not report a problem that the data does not show.
56
+ Return only JSON: {"hints":[{"text":"...","limits":{"max_context":80000}}]}.
57
+ If you find no problem, return {"hints":[]}.
58
+
59
+ Data:
60
+ TEXT
61
+
62
+ # Call is one tool call: its kind, the start of its command, and the size of its output.
63
+ Call = Struct.new(:kind, :text, :size)
64
+
65
+ # A shell command often starts with `source .maf/env.sh;` or `cd DIR &&`. Its kind is the command after that.
66
+ SETUP = /\A(\s*(source|cd)\s+\S+[^;&]*(;|&&))+\s*/
67
+
68
+ def self.call(tool, command, size)
69
+ kind = command ? KINDS.find { |_name, pattern| command.match?(pattern) }&.first : tool.to_s.downcase
70
+ text = (command || tool).to_s.gsub(/\s+/, " ")[0, 100]
71
+ Call.new(kind || command.sub(SETUP, "").split.first.to_s, text, size.to_i)
72
+ end
73
+
74
+ # Transcripts reads the tool calls of one session from the files of the harness.
75
+ module Transcripts
76
+ def self.calls(harness, id)
77
+ return [] if id.to_s.empty?
78
+
79
+ { "claude" => -> { claude(id) }, "codex" => -> { codex(id) }, "opencode" => -> { opencode(id) } }
80
+ .fetch(harness, -> { [] }).call
81
+ end
82
+
83
+ def self.lines(path) = path ? File.foreach(path).filter_map { |line| JSON.parse(line) rescue nil } : []
84
+
85
+ # Claude Code: a tool_use block, then a tool_result block with the same ID.
86
+ def self.claude(id)
87
+ events = lines(Dir.glob(File.join(Dir.home, ".claude", "projects", "*", "#{id}.jsonl")).first)
88
+ blocks = events.flat_map { |event| Array(event.dig("message", "content")).select { |block| block.is_a?(Hash) } }
89
+ sizes = blocks.select { |block| block["type"] == "tool_result" }
90
+ .to_h { |block| [block["tool_use_id"], block["content"].to_s.size] }
91
+ blocks.select { |block| block["type"] == "tool_use" }
92
+ .map { |block| Analyst.call(block["name"], block.dig("input", "command"), sizes[block["id"]]) }
93
+ end
94
+
95
+ # Codex: a custom_tool_call or function_call, then its output with the same call_id.
96
+ def self.codex(id)
97
+ events = lines(Dir.glob(File.join(Dir.home, ".codex", "sessions", "**", "rollout-*#{id}.jsonl")).first)
98
+ payloads = events.map { |event| event["payload"] || {} }
99
+ sizes = payloads.select { |p| p["type"].to_s.end_with?("_output") }
100
+ .to_h { |p| [p["call_id"], p["output"].to_s.size] }
101
+ payloads.select { |p| %w[custom_tool_call function_call].include?(p["type"]) }
102
+ .map { |p| Analyst.call(p["name"], codex_command(p), sizes[p["call_id"]]) }
103
+ end
104
+
105
+ def self.codex_command(payload)
106
+ text = (payload["input"] || payload["arguments"]).to_s
107
+ text[/cmd["']?\s*:\s*["']((?:[^"'\\]|\\.)*)/, 1] || text
108
+ end
109
+
110
+ # opencode keeps its sessions in a SQLite database. The sqlite3 CLI reads it.
111
+ def self.opencode(id)
112
+ db = File.join(Dir.home, ".local", "share", "opencode", "opencode.db")
113
+ return [] unless File.exist?(db) && id.match?(/\A[\w-]+\z/)
114
+
115
+ out, status = Open3.capture2("sqlite3", "-readonly", "-json", db, opencode_query(id))
116
+ status.success? ? JSON.parse(out.empty? ? "[]" : out).map { |row| Analyst.call(*row.values) } : []
117
+ rescue SystemCallError, JSON::ParserError
118
+ []
119
+ end
120
+
121
+ def self.opencode_query(id)
122
+ "select json_extract(data,'$.tool') tool, json_extract(data,'$.state.input.command') command, " \
123
+ "length(json_extract(data,'$.state.output')) size from part where session_id = '#{id}' " \
124
+ "and json_extract(data,'$.type') = 'tool'"
125
+ end
126
+ end
127
+
128
+ # Digest is the data that the model reads: the runs and the tool calls.
129
+ class Digest
130
+ def initialize(coord, worker)
131
+ @coord, @worker = coord, worker
132
+ end
133
+
134
+ def to_h
135
+ { worker: @worker, harness: entry["harness"], model: status["model"], limits: runs.last&.fetch("limits", nil),
136
+ runs: runs.map { |run| run_row(run) }, tools: tools }
137
+ end
138
+
139
+ def runs = @runs ||= history.last(HISTORY)
140
+
141
+ private
142
+
143
+ def entry = read(File.join(@coord, "workers.json")).fetch(@worker, {})
144
+ def status = @status ||= read(File.join(@coord, "status", "#{@worker}.json"))
145
+
146
+ def history
147
+ path = File.join(@coord, "usage", "#{@worker}.runs.jsonl")
148
+ File.exist?(path) ? File.foreach(path).filter_map { |line| JSON.parse(line) rescue nil } : []
149
+ end
150
+
151
+ def run_row(run)
152
+ run.slice("session_run", "input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens",
153
+ "context", "peak")
154
+ end
155
+
156
+ def tools
157
+ calls = Transcripts.calls(entry["harness"], status["session_id"])
158
+ { session: status["session_id"], calls: calls.size, output_chars: calls.sum(&:size),
159
+ by_kind: calls.group_by(&:kind).transform_values(&:size).sort_by { -_2 }.to_h,
160
+ largest: calls.max_by(TOP, &:size).map { |call| { size: call.size, call: call.text } } }
161
+ end
162
+
163
+ def read(path)
164
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
165
+ rescue JSON::ParserError
166
+ {}
167
+ end
168
+ end
169
+
170
+ # Model sends the prompt to the analyst command and reads the hints.
171
+ module Model
172
+ def self.ask(command, prompt)
173
+ out, err, status = Open3.capture3(MODEL_ENV, *command, stdin_data: prompt, chdir: Dir.tmpdir)
174
+ raise "#{command.first} failed: #{(err.empty? ? out : err).strip[0, 200]}" unless status.success?
175
+
176
+ reply = JSON.parse(out) rescue nil
177
+ reply = {} unless reply.is_a?(Hash)
178
+ [hints(reply["result"] || out), reply["total_cost_usd"]]
179
+ end
180
+
181
+ def self.hints(text)
182
+ data = JSON.parse(text.to_s[/\{\s*"hints".*\}/m].to_s)
183
+ Array(data["hints"]).first(3).filter_map { |hint| clean(hint) }
184
+ rescue JSON::ParserError
185
+ raise "the model returned no JSON: #{text.to_s.strip[0, 200]}"
186
+ end
187
+
188
+ # Only known limits with integer values reach the restart button.
189
+ def self.clean(hint)
190
+ return unless hint.is_a?(Hash) && !hint["text"].to_s.strip.empty?
191
+
192
+ limits = limits(hint["limits"])
193
+ { "text" => hint["text"].to_s.strip[0, 400], "limits" => (limits unless limits.empty?) }.compact
194
+ end
195
+
196
+ def self.limits(data)
197
+ return {} unless data.is_a?(Hash)
198
+
199
+ data.select { |key, value| LIMITS.include?(key) && value.is_a?(Integer) && value >= 0 }
200
+ end
201
+ end
202
+
203
+ # Main writes the state of the analysis to hints/<worker>.json: running first, then the hints or the error.
204
+ class Main
205
+ def initialize(argv)
206
+ @coord = ".maf/coordination"
207
+ @print = false
208
+ parser = OptionParser.new("Usage: analyst WORKER [--coord DIR] [--print]")
209
+ parser.on("--coord DIR") { |dir| @coord = dir }.on("--print") { @print = true }.parse!(argv)
210
+ @worker = argv.first || abort(parser.banner)
211
+ end
212
+
213
+ def run
214
+ return puts(prompt) if @print
215
+
216
+ write("running" => true)
217
+ hints, cost = Model.ask(command, prompt)
218
+ write("hints" => hints, "cost_usd" => cost)
219
+ rescue StandardError => e
220
+ write("error" => e.message)
221
+ end
222
+
223
+ private
224
+
225
+ def prompt = PROMPT + JSON.pretty_generate(Digest.new(@coord, @worker).to_h)
226
+
227
+ def command
228
+ manifest = JSON.parse(File.read(File.join(File.dirname(@coord), "config.json"))) rescue {}
229
+ Array(manifest.dig("team", "analyst", "command")).then { |list| list.empty? ? DEFAULT_COMMAND : list }
230
+ end
231
+
232
+ def write(data)
233
+ path = File.join(@coord, "hints", "#{@worker}.json")
234
+ FileUtils.mkdir_p(File.dirname(path))
235
+ File.write(path, JSON.generate({ "at" => Time.now.utc.iso8601 }.merge(data)))
236
+ end
237
+ end
238
+ end
239
+
240
+ Analyst::Main.new(ARGV).run if __FILE__ == $PROGRAM_NAME