maf 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +22 -1
- data/assets/agents-contract.md +2 -0
- data/assets/coord +145 -21
- data/assets/dispatcher +67 -13
- data/install.md +6 -1
- data/lib/maf/backup.rb +63 -0
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/budget.rb +2 -0
- data/lib/maf/cli.rb +38 -10
- data/lib/maf/flow/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +2 -1
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/manifest.rb +1 -1
- data/lib/maf/flow/models.rb +11 -0
- data/lib/maf/flow/options.rb +29 -3
- data/lib/maf/flow/prompt_text.rb +14 -7
- data/lib/maf/flow/report.rb +6 -5
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +5 -5
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow.rb +3 -1
- data/lib/maf/menu.rb +48 -14
- data/lib/maf/restore.rb +62 -0
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +2 -2
- data/lib/maf/setup_agent/launcher.rb +6 -4
- data/lib/maf/setup_agent/manifest.rb +9 -3
- data/lib/maf/setup_agent.rb +11 -11
- data/lib/maf/shared/harness/defaults.rb +10 -3
- data/lib/maf/shared/harnesses/claude.rb +6 -3
- data/lib/maf/shared/harnesses/codex.rb +39 -10
- data/lib/maf/shared/harnesses/cursor.rb +60 -4
- data/lib/maf/shared/harnesses/hermes.rb +21 -11
- data/lib/maf/shared/harnesses/opencode.rb +25 -2
- data/lib/maf/shared/roles.rb +4 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +5 -1
- data/lib/maf/shared/sandbox.rb +6 -0
- data/lib/maf/team.rb +15 -6
- data/lib/maf/team_command.rb +11 -2
- data/lib/maf/untrack.rb +12 -1
- data/lib/maf/version.rb +1 -1
- data/lib/maf/worker_control.rb +12 -1
- data/lib/maf/worker_list.rb +83 -0
- data/templates/roles.yml +14 -0
- metadata +6 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
4
|
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
5
|
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "open3"
|
|
6
7
|
require_relative "../harness/traits"
|
|
7
8
|
|
|
8
9
|
module Maf
|
|
@@ -37,7 +38,7 @@ module Maf
|
|
|
37
38
|
name: <%= role %>
|
|
38
39
|
description: <%= description %>
|
|
39
40
|
<% if model -%>
|
|
40
|
-
model: <%= model %>
|
|
41
|
+
model: <%= Maf::Shared::Harnesses::CursorModels.name(model, effort) %>
|
|
41
42
|
<% end -%>
|
|
42
43
|
readonly: <%= !can_edit %>
|
|
43
44
|
---
|
|
@@ -45,20 +46,75 @@ module Maf
|
|
|
45
46
|
<%= prompt %>
|
|
46
47
|
ERB
|
|
47
48
|
|
|
48
|
-
def launch(role, model)
|
|
49
|
-
|
|
49
|
+
def launch(role, model, effort = nil)
|
|
50
|
+
warn "maf: Cursor takes the level in the model name. Without a model, maf drops --effort." if effort && !model
|
|
51
|
+
["agent", *model_flag(CursorModels.name(model, effort)),
|
|
52
|
+
"Read #{role_path(role)} and follow it exactly. Start your work loop now."]
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
# `agent -p --output-format json` prints one result object with session_id and result.
|
|
53
56
|
def build_command(config, prompt, session_id)
|
|
54
|
-
%w[agent -p --output-format json --force --trust
|
|
57
|
+
%w[agent -p --output-format json --force --trust
|
|
58
|
+
--approve-mcps] + model_flag(CursorModels.name(config.model, config.effort)) +
|
|
55
59
|
resume(session_id, "--resume") + [first_prompt(config.role, prompt, session_id)]
|
|
56
60
|
end
|
|
57
61
|
|
|
62
|
+
# Cursor has no effort flag. The level is a part of the model name, so
|
|
63
|
+
# maf lists each model without its level, and the levels of each model.
|
|
64
|
+
def models = CursorModels.bases
|
|
65
|
+
|
|
66
|
+
def known_model?(model, list = models)
|
|
67
|
+
list.nil? || list.include?(model) || CursorModels.listed.include?(model)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def efforts(model = nil) = CursorModels.efforts(model)
|
|
71
|
+
def effort_required?(model) = CursorModels.effort_required?(model)
|
|
72
|
+
|
|
58
73
|
# A failed run prints no JSON, only an error on stderr. So a resume
|
|
59
74
|
# without a result event and with "not found" is stale. Cursor reports no token usage.
|
|
60
75
|
def stale_session?(output) = result_event(output).empty? && output.match?(/not found/i)
|
|
61
76
|
end
|
|
77
|
+
|
|
78
|
+
# CursorModels reads `agent --list-models`. A name is BASE-LEVEL or
|
|
79
|
+
# BASE-LEVEL-fast, for example gpt-5.3-codex-high-fast.
|
|
80
|
+
module CursorModels
|
|
81
|
+
LEVELS = %w[low medium high xhigh max].freeze
|
|
82
|
+
LEVEL = /-(#{LEVELS.join("|")})(?=(?:-fast)?\z)/
|
|
83
|
+
|
|
84
|
+
def self.listed
|
|
85
|
+
return @listed if defined?(@listed)
|
|
86
|
+
|
|
87
|
+
@listed = read
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.read
|
|
91
|
+
out, status = Open3.capture2("agent", "--list-models", err: File::NULL)
|
|
92
|
+
status.success? ? out.scan(/^(\S+) - /).flatten : nil
|
|
93
|
+
rescue SystemCallError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.bases = listed&.map { |model| model.sub(LEVEL, "") }&.uniq
|
|
98
|
+
|
|
99
|
+
# nil: maf cannot read the list. A name with a level takes no other level.
|
|
100
|
+
def self.efforts(model)
|
|
101
|
+
return nil unless listed
|
|
102
|
+
return [] if model.nil? || (model.match?(LEVEL) && listed.include?(model))
|
|
103
|
+
|
|
104
|
+
LEVELS.select { |level| listed.include?(name(model, level)) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A base that Cursor does not list by itself runs only with a level.
|
|
108
|
+
def self.effort_required?(model) = !listed.nil? && !listed.include?(model) && !efforts(model).to_a.empty?
|
|
109
|
+
|
|
110
|
+
# A name with a level gets the new level instead.
|
|
111
|
+
def self.name(model, effort)
|
|
112
|
+
return model unless model && effort
|
|
113
|
+
|
|
114
|
+
model = model.sub(LEVEL, "")
|
|
115
|
+
model.end_with?("-fast") ? model.sub(/-fast\z/, "-#{effort}-fast") : "#{model}-#{effort}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
62
118
|
end
|
|
63
119
|
end
|
|
64
120
|
end
|
|
@@ -13,16 +13,14 @@ module Maf
|
|
|
13
13
|
# outside the project. `--skills <project>-<role>` loads one. Hermes has
|
|
14
14
|
# no agent flag and cannot be woken, so an idle agent blocks in
|
|
15
15
|
# `coord next --wait`. A role with can_edit false starts without the
|
|
16
|
-
# file-writing toolsets.
|
|
16
|
+
# file-writing toolsets. No role gets the kanban or delegation
|
|
17
|
+
# toolset. Hermes keeps hooks and MCP servers in global
|
|
17
18
|
# files, so maf prints the commands that add them.
|
|
18
19
|
class Hermes < Harness::Base
|
|
19
20
|
include Harness::ResultEvent
|
|
20
21
|
|
|
21
22
|
DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
|
|
22
23
|
MAX_TURNS = 50
|
|
23
|
-
# Stop Hermes before the dispatcher kills it, so it can still write its
|
|
24
|
-
# result event. NOTE: assets/dispatcher Spawn::GRACE.
|
|
25
|
-
KILL_GRACE = 5
|
|
26
24
|
|
|
27
25
|
def sandbox_dirs = [File.join(Dir.home, ".hermes")]
|
|
28
26
|
PROTECTED = %w[config.yaml .env shell_init.sh SOUL.md hooks agent-hooks shell-hooks-allowlist.json bin scripts
|
|
@@ -56,8 +54,11 @@ module Maf
|
|
|
56
54
|
ERB
|
|
57
55
|
|
|
58
56
|
def model_hint(_model) = "hermes model"
|
|
57
|
+
EFFORTS = %w[none minimal low medium high xhigh max ultra].freeze
|
|
58
|
+
def efforts(_model = nil) = EFFORTS
|
|
59
|
+
def effort_flag(effort) = super(effort, "--reasoning")
|
|
59
60
|
def mcp_command(server, vault) = "hermes mcp add #{server} --command ruby --args #{vault} mcp"
|
|
60
|
-
def install_hooks(_project) = Flow::HermesHookSetup.new.install
|
|
61
|
+
def install_hooks(_project) = Flow::HermesHookSetup.new.install | Flow::HermesEnvSetup.new.install
|
|
61
62
|
def global_files = [File.join(Dir.home, ".hermes", "agent-hooks", "next-task.sh")]
|
|
62
63
|
|
|
63
64
|
# The skill name uses the main checkout's name, from the project root
|
|
@@ -74,7 +75,11 @@ module Maf
|
|
|
74
75
|
Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
# With -t, Hermes loads an MCP server only when the list names its toolset.
|
|
79
|
+
def tools(role)
|
|
80
|
+
toolsets = read_only?(role) ? Roles::READ_ONLY_TOOLSETS : Roles::EDIT_TOOLSETS
|
|
81
|
+
["-t", "#{toolsets},mcp-graphify-#{File.basename(Project.root)}"]
|
|
82
|
+
end
|
|
78
83
|
|
|
79
84
|
PROMPT = "Follow the coordination rules of your role skill as role %<role>s. " \
|
|
80
85
|
"Run coord inbox, then coord next --wait. Claim a task, do the work, finish it. Repeat."
|
|
@@ -82,10 +87,10 @@ module Maf
|
|
|
82
87
|
"You are a lead role. Never claim a task. Run coord inbox --wait and handle each message."
|
|
83
88
|
|
|
84
89
|
# `-q` on a TTY seeds an interactive session with the prompt.
|
|
85
|
-
def launch(role, model)
|
|
90
|
+
def launch(role, model, effort = nil)
|
|
86
91
|
prompt = format(Roles::LEADS.include?(role) ? LEAD_PROMPT : PROMPT, role: role)
|
|
87
|
-
["hermes", "chat", *skill(role)&.then { |name| ["--skills", name] }, *model_flag(model), *
|
|
88
|
-
"-q", prompt]
|
|
92
|
+
["hermes", "chat", *skill(role)&.then { |name| ["--skills", name] }, *model_flag(model), *effort_flag(effort),
|
|
93
|
+
*tools(role), "-q", prompt]
|
|
89
94
|
end
|
|
90
95
|
|
|
91
96
|
# --no-skill drops the skill. Without --skill, the generated skill of the role loads.
|
|
@@ -99,11 +104,16 @@ module Maf
|
|
|
99
104
|
# `hermes chat --oneshot` with JSONL output and the skill preloaded.
|
|
100
105
|
def build_command(config, prompt, session_id)
|
|
101
106
|
cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q]
|
|
102
|
-
cmd +=
|
|
103
|
-
cmd += ["--max-turns", config.max_turns.to_s
|
|
107
|
+
cmd += run_flags(config)
|
|
108
|
+
cmd += ["--max-turns", config.max_turns.to_s]
|
|
104
109
|
cmd + resume(session_id, "--resume") + ["-q", prompt]
|
|
105
110
|
end
|
|
106
111
|
|
|
112
|
+
def run_flags(config)
|
|
113
|
+
[*(["--skills", config.skill] if config.skill), *model_flag(config.model), *effort_flag(config.effort),
|
|
114
|
+
*tools(config.role)]
|
|
115
|
+
end
|
|
116
|
+
|
|
107
117
|
def stale_session?(output) = output.include?("Session not found")
|
|
108
118
|
def result_text(output) = result_event(output)["text"]
|
|
109
119
|
end
|
|
@@ -37,6 +37,9 @@ module Maf
|
|
|
37
37
|
<% if model -%>
|
|
38
38
|
model: <%= model %>
|
|
39
39
|
<% end -%>
|
|
40
|
+
<% if effort -%>
|
|
41
|
+
variant: <%= effort %>
|
|
42
|
+
<% end -%>
|
|
40
43
|
temperature: 0.1
|
|
41
44
|
permission:
|
|
42
45
|
edit: <%= can_edit ? "allow" : "deny" %>
|
|
@@ -92,15 +95,19 @@ module Maf
|
|
|
92
95
|
def run_env(config) = config_env(run_file(config, "mcp", "opencode.json"))
|
|
93
96
|
def config_env(file) = File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
# The TUI has no variant flag. It reads the variant from the agent file.
|
|
99
|
+
def launch(role, model, effort = nil)
|
|
100
|
+
OpencodeVariant.warn_unused(File.join(role_dir, "#{role}.md"), role, effort)
|
|
96
101
|
["opencode", ".", "--agent", role, "--prompt", "Start your work loop now.", *model_flag(model)]
|
|
97
102
|
end
|
|
98
103
|
|
|
104
|
+
def effort_flag(effort) = super(effort, "--variant")
|
|
105
|
+
|
|
99
106
|
# `opencode run --format json`. The result event shape is not verified,
|
|
100
107
|
# so the log shows no result text.
|
|
101
108
|
def build_command(config, prompt, session_id)
|
|
102
109
|
%w[opencode run --format json --agent] + [config.role] + model_flag(config.model, "-m") +
|
|
103
|
-
resume(session_id, "--session") + [prompt]
|
|
110
|
+
effort_flag(config.effort) + resume(session_id, "--session") + [prompt]
|
|
104
111
|
end
|
|
105
112
|
|
|
106
113
|
# DeepSeek costs twice as much at peak hours.
|
|
@@ -164,6 +171,22 @@ module Maf
|
|
|
164
171
|
"and json_extract(data,'$.type') = 'tool'"
|
|
165
172
|
end
|
|
166
173
|
end
|
|
174
|
+
|
|
175
|
+
# OpencodeVariant tells the user how to change the level of an interactive session.
|
|
176
|
+
module OpencodeVariant
|
|
177
|
+
PATTERN = /\A---\n(?:(?!^---$).)*?^variant:\s*(\S+)/m
|
|
178
|
+
HINT = "maf: the opencode TUI cannot take --effort %<effort>s. The session starts with %<current>s. " \
|
|
179
|
+
"In the session, press ctrl+t to pick the variant %<effort>s. If the agent is in its work " \
|
|
180
|
+
"loop, stop it (Esc) and restart the loop: \"Start your work loop now.\" " \
|
|
181
|
+
"To keep the level for each session: maf add opencode:%<role>s --effort %<role>s=%<effort>s"
|
|
182
|
+
|
|
183
|
+
def self.warn_unused(path, role, effort)
|
|
184
|
+
current = File.exist?(path) ? File.read(path)[PATTERN, 1] : nil
|
|
185
|
+
return if effort.nil? || effort == current
|
|
186
|
+
|
|
187
|
+
warn format(HINT, effort: effort, role: role, current: current ? "variant #{current}" : "the default variant")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
167
190
|
end
|
|
168
191
|
end
|
|
169
192
|
end
|
data/lib/maf/shared/roles.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Maf
|
|
|
14
14
|
# Hermes toolsets for a role with can_edit false: no file, code_execution,
|
|
15
15
|
# or delegation toolset. The shell stays, because the role needs coord and git.
|
|
16
16
|
READ_ONLY_TOOLSETS = "terminal,web,skills,todo,memory,session_search,clarify"
|
|
17
|
+
|
|
18
|
+
# Hermes toolsets for a role that edits. coord is the only task board and
|
|
19
|
+
# maf starts the workers, so kanban, delegation, and cronjob stay off.
|
|
20
|
+
EDIT_TOOLSETS = "#{READ_ONLY_TOOLSETS},file,code_execution,browser,vision".freeze
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
end
|
|
@@ -11,6 +11,7 @@ module Maf
|
|
|
11
11
|
# Seatbelt runs a command in a macOS sandbox-exec profile.
|
|
12
12
|
module Seatbelt
|
|
13
13
|
COMMAND = "/usr/bin/sandbox-exec"
|
|
14
|
+
MARKER = "MAF_SEATBELT"
|
|
14
15
|
DEVICES = '(literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") ' \
|
|
15
16
|
'(regex #"^/dev/tty") (regex #"^/dev/fd/") (regex #"^/dev/ptmx")'
|
|
16
17
|
DNS = '(path-literal "/private/var/run/mDNSResponder")'
|
|
@@ -20,9 +21,12 @@ module Maf
|
|
|
20
21
|
|
|
21
22
|
def self.wrap(cmd, paths)
|
|
22
23
|
tmp = private_tmp(paths.root)
|
|
23
|
-
[COMMAND, "-p", profile(paths, tmp), "env", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache",
|
|
24
|
+
[COMMAND, "-p", profile(paths, tmp), "env", "#{MARKER}=1", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache",
|
|
25
|
+
*cmd]
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
def self.inside? = ENV[MARKER] == "1"
|
|
29
|
+
|
|
26
30
|
def self.available? = File.executable?(COMMAND)
|
|
27
31
|
def self.require! = available? || abort("maf: sandbox mode seatbelt needs #{COMMAND} (macOS only).")
|
|
28
32
|
|
data/lib/maf/shared/sandbox.rb
CHANGED
|
@@ -18,6 +18,8 @@ module Maf
|
|
|
18
18
|
BACKENDS = { "seatbelt" => Seatbelt, "bwrap" => Bwrap }.freeze
|
|
19
19
|
MODES = ["off", "auto", *BACKENDS.keys].freeze
|
|
20
20
|
POLICY = File.join(".maf", "sandbox.json")
|
|
21
|
+
NESTED = "maf: this process runs in the macOS sandbox, and macOS cannot start a second sandbox in it. " \
|
|
22
|
+
"Start the worker from your terminal: cd <worktree> && maf start"
|
|
21
23
|
|
|
22
24
|
def self.policy
|
|
23
25
|
path = File.join(Project.root, POLICY)
|
|
@@ -40,9 +42,13 @@ module Maf
|
|
|
40
42
|
def self.wrap(cmd, harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
41
43
|
policy = self.policy
|
|
42
44
|
mode = mode(policy)
|
|
45
|
+
refuse_nested!(mode)
|
|
43
46
|
mode == "off" ? cmd : BACKENDS[mode].wrap(harness.sandboxed(cmd), paths(harness, policy, coord_dir))
|
|
44
47
|
end
|
|
45
48
|
|
|
49
|
+
def self.nested?(mode = self.mode) = mode == "seatbelt" && Seatbelt.inside?
|
|
50
|
+
def self.refuse_nested!(mode = self.mode) = nested?(mode) && abort(NESTED)
|
|
51
|
+
|
|
46
52
|
def self.keeping_links(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
47
53
|
links = protected_links(harness, coord_dir)
|
|
48
54
|
yield
|
data/lib/maf/team.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# team.rb - add, replace, and retire workers in one command.
|
|
4
4
|
#
|
|
5
|
-
# maf prepare HARNESS ROLE[_WORKER] [--model M] [--dispatch | --interactive] [--replace WORKER]
|
|
5
|
+
# maf prepare HARNESS ROLE[_WORKER] [--model M] [--effort L] [--dispatch | --interactive] [--replace WORKER]
|
|
6
6
|
# maf retire WORKER
|
|
7
7
|
#
|
|
8
8
|
# The project manager runs these commands. `maf prepare` adds the role file,
|
|
@@ -24,6 +24,7 @@ module Maf
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def self.notify(root, text) = coord(root, "msg", "--from", "maf", "architect", text)
|
|
27
|
+
def self.agent? = !ENV["COORD_ROLE"].to_s.empty?
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# Prepare makes a worker ready to start. With --replace, it retires the
|
|
@@ -35,6 +36,8 @@ module Maf
|
|
|
35
36
|
# The architect never talks to the user. A dispatched architect gets fresh
|
|
36
37
|
# sessions with a handoff note, so its context stays small.
|
|
37
38
|
DISPATCH_DEFAULT = %w[architect].freeze
|
|
39
|
+
NO_BUDGET = "maf: an agent cannot change the team without a budget. Ask the user to set one " \
|
|
40
|
+
"(maf team set --max N), or to run this command in a terminal."
|
|
38
41
|
|
|
39
42
|
def self.mode_args(argv)
|
|
40
43
|
return argv - ["--interactive"] if argv.include?("--interactive")
|
|
@@ -52,7 +55,7 @@ module Maf
|
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
def run
|
|
55
|
-
|
|
58
|
+
check_budget
|
|
56
59
|
retire = (Retire.new(@root, @replace).tap(&:check!) if @replace)
|
|
57
60
|
Dir.chdir(@root) { prepare }
|
|
58
61
|
retire.run if retire && Workers.id(@replace) != @args.worker
|
|
@@ -61,18 +64,23 @@ module Maf
|
|
|
61
64
|
|
|
62
65
|
def announce
|
|
63
66
|
Team.notify(@root, "Team change: worker #{@args.worker} (#{@args.harness}) joined as #{@args.role}.")
|
|
64
|
-
@args.dispatch ? start_dispatcher : print_next_steps
|
|
67
|
+
@args.dispatch && !Shared::Sandbox.nested? ? start_dispatcher : print_next_steps
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
private
|
|
68
71
|
|
|
72
|
+
def check_budget
|
|
73
|
+
abort NO_BUDGET if Team.agent? && !@budget.limited?
|
|
74
|
+
@budget.check!(@args.harness, @args.model, @budget.count(workers_after))
|
|
75
|
+
end
|
|
76
|
+
|
|
69
77
|
def take_replace(argv)
|
|
70
78
|
index = argv.index("--replace") or return nil
|
|
71
79
|
argv.slice!(index, 2)[1] || abort("maf: --replace needs a worker, for example backend-developer_2")
|
|
72
80
|
end
|
|
73
81
|
|
|
74
82
|
def prepare
|
|
75
|
-
Maf.flow("--agent", spec) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
|
|
83
|
+
Maf.flow("--agent", spec, *effort_flag) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
|
|
76
84
|
worktree = SetupAgent::Worktree.ensure(@args.role, @args.worker_id, @args.harness)
|
|
77
85
|
SetupAgent::RoleFile.copy(@root, worktree.dir, @args.harness, @args.role)
|
|
78
86
|
archive_on_harness_change
|
|
@@ -87,6 +95,7 @@ module Maf
|
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
def spec = [@args.harness, @args.role, @args.model].compact.join(":")
|
|
98
|
+
def effort_flag = @args.effort ? ["--effort", "#{@args.role}=#{@args.effort}"] : []
|
|
90
99
|
|
|
91
100
|
def workers_after
|
|
92
101
|
all = Workers.at(@root).all
|
|
@@ -98,12 +107,12 @@ module Maf
|
|
|
98
107
|
def start_dispatcher
|
|
99
108
|
Dir.chdir(@root)
|
|
100
109
|
args = [@args.harness, "#{@args.role}_#{@args.worker_id}", "--dispatch", "--detach"]
|
|
101
|
-
SetupAgent.run(
|
|
110
|
+
SetupAgent.run(args + { "--model" => @args.model, "--effort" => @args.effort }.compact.flatten)
|
|
102
111
|
end
|
|
103
112
|
|
|
104
113
|
def entry(dir)
|
|
105
114
|
{ "role" => @args.role, "worker_id" => @args.worker_id, "harness" => @args.harness,
|
|
106
|
-
"model" => @args.model, "dispatch" => @args.dispatch, "dir" => dir }.compact
|
|
115
|
+
"model" => @args.model, "effort" => @args.effort, "dispatch" => @args.dispatch, "dir" => dir }.compact
|
|
107
116
|
end
|
|
108
117
|
|
|
109
118
|
def print_next_steps
|
data/lib/maf/team_command.rb
CHANGED
|
@@ -18,7 +18,14 @@ module Maf
|
|
|
18
18
|
@root = root
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
AGENT_SET = "maf: only the user sets the team budget. Give the user this command to run in a terminal."
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
return show unless @argv.first == "set"
|
|
25
|
+
|
|
26
|
+
abort AGENT_SET if Team.agent?
|
|
27
|
+
set(@argv.drop(1))
|
|
28
|
+
end
|
|
22
29
|
|
|
23
30
|
private
|
|
24
31
|
|
|
@@ -32,10 +39,12 @@ module Maf
|
|
|
32
39
|
|
|
33
40
|
def line(id, entry)
|
|
34
41
|
mode = entry["dispatch"] ? "dispatch" : "interactive"
|
|
35
|
-
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry
|
|
42
|
+
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], model(entry), mode, state(id, entry)]
|
|
36
43
|
.join(" ")
|
|
37
44
|
end
|
|
38
45
|
|
|
46
|
+
def model(entry) = [entry["model"] || "-", entry["effort"]].compact.join("/")
|
|
47
|
+
|
|
39
48
|
def state(id, entry)
|
|
40
49
|
pid = entry["pid"]
|
|
41
50
|
return "-" unless pid
|
data/lib/maf/untrack.rb
CHANGED
|
@@ -18,6 +18,15 @@ module Maf
|
|
|
18
18
|
@yes = argv.include?("--yes")
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
TRACKED = "maf: git tracks files of the flow. Each maf update changes them in every worktree, " \
|
|
22
|
+
"and coord start-task refuses a dirty worktree. Run maf untrack --check, then maf untrack, then commit."
|
|
23
|
+
|
|
24
|
+
def self.warn_tracked(root) = (warn TRACKED unless new(root, []).tracked.empty?)
|
|
25
|
+
|
|
26
|
+
DONE = ["", "The flow left git. Review the change with `git status`, then commit it.",
|
|
27
|
+
"A branch that still tracks the flow files can delete them in a checkout or a merge.",
|
|
28
|
+
"maf keeps a backup of the flow config in .git/maf/backup. Run `maf restore` to put it back."].freeze
|
|
29
|
+
|
|
21
30
|
def run
|
|
22
31
|
steps = plan
|
|
23
32
|
return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
|
|
@@ -50,7 +59,7 @@ module Maf
|
|
|
50
59
|
abort "maf: cancelled" unless @yes || confirmed?
|
|
51
60
|
steps.each(&:run)
|
|
52
61
|
Dir.chdir(@root) { Maf.flow }
|
|
53
|
-
puts
|
|
62
|
+
puts DONE
|
|
54
63
|
end
|
|
55
64
|
|
|
56
65
|
def confirmed?
|
|
@@ -58,6 +67,8 @@ module Maf
|
|
|
58
67
|
$stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
|
|
59
68
|
end
|
|
60
69
|
|
|
70
|
+
public
|
|
71
|
+
|
|
61
72
|
# The tracked files of .maf/, and the tracked paths of each harness (Harness#untrack_paths).
|
|
62
73
|
def tracked
|
|
63
74
|
harness_paths = Maf::Shared::Harness.all.flat_map { |harness| harness.untrack_paths(@root, method(:git_files)) }
|
data/lib/maf/version.rb
CHANGED
data/lib/maf/worker_control.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Maf
|
|
|
26
26
|
@root = root
|
|
27
27
|
@force = force
|
|
28
28
|
@limits = limits
|
|
29
|
-
@worker = Workers.id(spec)
|
|
29
|
+
@worker = resolve(Workers.at(root).all, Workers.id(spec))
|
|
30
30
|
@entry = Workers.at(root).find(@worker) || abort("maf: no worker #{@worker}")
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -39,6 +39,17 @@ module Maf
|
|
|
39
39
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
|
+
def resolve(workers, worker)
|
|
43
|
+
return worker if workers.key?(worker)
|
|
44
|
+
|
|
45
|
+
only_worker(worker, workers.select { |_, entry| entry["role"] == worker }.keys) || worker
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def only_worker(role, names)
|
|
49
|
+
abort "maf: role #{role} has more than one worker: #{names.join(", ")}. Name one." if names.size > 1
|
|
50
|
+
names.first
|
|
51
|
+
end
|
|
52
|
+
|
|
42
53
|
def status = puts("#{@worker}: #{live? ? "running (pid #{pid})" : "stopped"}")
|
|
43
54
|
|
|
44
55
|
def stop
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# worker_list.rb - list the workers that run now: sessions and dispatchers.
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "workers"
|
|
7
|
+
require_relative "shared/processes"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
# WorkerList shows each running worker process of the project. A worker
|
|
11
|
+
# can run twice, for example as a session and as a dispatcher.
|
|
12
|
+
class WorkerList
|
|
13
|
+
Row = Struct.new(:worker, :role, :harness, :mode, :pid, :started)
|
|
14
|
+
HEADER = %w[WORKER ROLE HARNESS MODE PID STARTED].freeze
|
|
15
|
+
LSTART = "%a %b %d %H:%M:%S %Y %z"
|
|
16
|
+
|
|
17
|
+
def initialize(root)
|
|
18
|
+
@root = root
|
|
19
|
+
@workers = Workers.at(root)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
rows = running
|
|
24
|
+
puts rows.empty? ? "No worker runs." : table([HEADER] + rows.map { |row| row.to_a.map(&:to_s) }), warnings(rows)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def running = (sessions + dispatchers + registered).uniq(&:pid).sort_by { |row| [row.worker, row.mode] }
|
|
30
|
+
|
|
31
|
+
def coord_dir = File.join(@root, ".maf", "coordination")
|
|
32
|
+
def records(dir, pattern) = Dir.glob(File.join(coord_dir, dir, pattern)).map { |path| [path, read(path)] }
|
|
33
|
+
|
|
34
|
+
def sessions
|
|
35
|
+
records("sessions", "*.maf.json").filter_map do |path, record|
|
|
36
|
+
row(record, "session") if session_live?(record["pid"], File.mtime(path))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def session_live?(pid, registered)
|
|
41
|
+
started = Shared::Processes.started_at(pid)
|
|
42
|
+
Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def dispatchers
|
|
48
|
+
records("presence", "*.json").filter_map do |_, entry|
|
|
49
|
+
row(entry, "dispatch") if entry["mode"] == "dispatch" && Shared::Processes.live?(entry)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def registered
|
|
54
|
+
@workers.all.filter_map do |worker, entry|
|
|
55
|
+
row(entry.merge("worker" => worker), "dispatch") if @workers.dispatcher?(worker)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def row(data, mode)
|
|
60
|
+
entry = @workers.find(data["worker"]).to_h
|
|
61
|
+
Row.new(data["worker"], data["role"] || entry["role"], data["harness"] || entry["harness"], mode,
|
|
62
|
+
data["pid"].to_i, Shared::Processes.started_at(data["pid"]))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def table(lines)
|
|
66
|
+
widths = lines.transpose.map { |column| column.map(&:size).max }
|
|
67
|
+
lines.map { |line| line.zip(widths).map { |text, width| text.ljust(width) }.join(" ").rstrip }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def warnings(rows)
|
|
71
|
+
twice = rows.group_by(&:worker).select { |_, list| list.size > 1 }
|
|
72
|
+
stopped = @workers.all.keys - rows.map(&:worker)
|
|
73
|
+
twice.map { |worker, list| "warning: #{worker} runs #{list.size} times (pids #{list.map(&:pid).join(", ")})" } +
|
|
74
|
+
(stopped.empty? ? [] : ["stopped: #{stopped.join(", ")}"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read(path)
|
|
78
|
+
JSON.parse(File.read(path))
|
|
79
|
+
rescue JSON::ParserError, SystemCallError
|
|
80
|
+
{}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/templates/roles.yml
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
# the model with --model ROLE=MODEL. If the user does not choose a model,
|
|
5
5
|
# claude roles get lib/maf/flow.rb's DEFAULT_MODELS entry (claude-opus-5-5).
|
|
6
6
|
#
|
|
7
|
+
# `effort` is the default effort level. A role gets it when the harness lists
|
|
8
|
+
# the level. --effort ROLE=LEVEL overrides it. A lower level uses fewer tokens.
|
|
9
|
+
#
|
|
7
10
|
# `description` is also the routing line in the architect's "Available roles"
|
|
8
11
|
# list. Keep it short and specific to the role.
|
|
9
12
|
#
|
|
@@ -42,6 +45,7 @@ roles:
|
|
|
42
45
|
- If a message starts with "ESCALATION", a role cannot fix a problem. Tell the user in plain words:
|
|
43
46
|
what failed, which task, and the impact. Give two or three options with a recommended one.
|
|
44
47
|
Wait for the answer. Then send the answer to the role with `coord msg`.
|
|
48
|
+
If the escalation names a blocked task, run `coord unblock <id>` after you send the answer.
|
|
45
49
|
- If the architect escalates a repeated review failure, forward the
|
|
46
50
|
options to the user. Do not send a new goal until the user decides.
|
|
47
51
|
|
|
@@ -53,6 +57,7 @@ roles:
|
|
|
53
57
|
- Process detail in reports. The user needs outcomes and decisions.
|
|
54
58
|
- Optimistic status. Report blockers and failed checks as they are.
|
|
55
59
|
model_hint: "Strongest reasoning and long context, conversational. Cloud: Claude Opus 5.5 (default for the claude harness), GPT-5.x."
|
|
60
|
+
effort: medium
|
|
56
61
|
can_edit: false
|
|
57
62
|
|
|
58
63
|
architect:
|
|
@@ -84,6 +89,8 @@ roles:
|
|
|
84
89
|
Goal, Inputs, Out of scope, Acceptance, Report format.
|
|
85
90
|
A worker drifts if a field is missing.
|
|
86
91
|
- Write acceptance criteria as exact test files or commands. The worker runs each one before coord done.
|
|
92
|
+
- A message `BLOCKED task <id> (dependency)` waits for other work. Plan or order that work.
|
|
93
|
+
When it lands, run `coord unblock <id>`. A blocked task stays out of `coord next` until then.
|
|
87
94
|
- If a review rejects the same kind of problem two times, stop the fix
|
|
88
95
|
loop. Send the project manager two or three options with the cost of
|
|
89
96
|
each option. A third patch of the same kind usually fails too.
|
|
@@ -96,6 +103,7 @@ roles:
|
|
|
96
103
|
- Code in task specs. Describe behavior and constraints instead.
|
|
97
104
|
- Trust in a worker report without a diff check.
|
|
98
105
|
model_hint: "Strongest reasoning and long context. Cloud: Claude Opus 5.5 (default for the claude harness), GPT-5.x, deepseek-v4."
|
|
106
|
+
effort: high
|
|
99
107
|
can_edit: false
|
|
100
108
|
|
|
101
109
|
backend-developer:
|
|
@@ -123,6 +131,7 @@ roles:
|
|
|
123
131
|
- Edits to a test that looks wrong. Report the test to the architect.
|
|
124
132
|
- Claims about code that you did not read.
|
|
125
133
|
model_hint: "A strong code model. A local Qwen3.8-27B is enough for well-scoped tasks; use a cloud coder for hard or large changes."
|
|
134
|
+
effort: medium
|
|
126
135
|
can_edit: true
|
|
127
136
|
|
|
128
137
|
frontend-developer:
|
|
@@ -149,6 +158,7 @@ roles:
|
|
|
149
158
|
- Values that are hard-coded to make a test pass.
|
|
150
159
|
- Visual changes outside the task scope.
|
|
151
160
|
model_hint: "Same as backend-developer: local coder for scoped work, cloud coder for hard UI work."
|
|
161
|
+
effort: medium
|
|
152
162
|
can_edit: true
|
|
153
163
|
|
|
154
164
|
tester:
|
|
@@ -174,6 +184,7 @@ roles:
|
|
|
174
184
|
- Assertions that only repeat the implementation.
|
|
175
185
|
- Deleted or weakened tests. A test removal needs architect approval.
|
|
176
186
|
model_hint: "Mechanical test work usually fits a local coder model; use cloud for subtle failure analysis."
|
|
187
|
+
effort: low
|
|
177
188
|
can_edit: true
|
|
178
189
|
|
|
179
190
|
reviewer:
|
|
@@ -216,6 +227,7 @@ roles:
|
|
|
216
227
|
- Style comments that the project's linter already covers.
|
|
217
228
|
- A warning or minor finding labeled critical to force another round.
|
|
218
229
|
model_hint: "Judgment-heavy. Prefer a cloud model with strong review ability."
|
|
230
|
+
effort: high
|
|
219
231
|
can_edit: false
|
|
220
232
|
|
|
221
233
|
skeptic:
|
|
@@ -252,6 +264,7 @@ roles:
|
|
|
252
264
|
- A warning or minor finding labeled critical to force another round.
|
|
253
265
|
- Style comments. The reviewer and the linter cover style.
|
|
254
266
|
model_hint: "Judgment-heavy. Use a different model family from reviewer and auditor, for example GPT-5.x if reviewer runs Claude. Different models miss different defects."
|
|
267
|
+
effort: high
|
|
255
268
|
can_edit: false
|
|
256
269
|
|
|
257
270
|
auditor:
|
|
@@ -286,4 +299,5 @@ roles:
|
|
|
286
299
|
- A warning or minor finding labeled critical to force another round.
|
|
287
300
|
- Style comments. The reviewer and the linter cover style.
|
|
288
301
|
model_hint: "Judgment-heavy. Use a different model family from reviewer and skeptic, for example Gemini, Grok, or a strong local model. Different models miss different defects."
|
|
302
|
+
effort: high
|
|
289
303
|
can_edit: false
|