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
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
|