maf 0.2.0 → 0.3.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 +43 -0
- data/README.md +18 -1
- data/assets/agents-contract.md +2 -0
- data/assets/coord +145 -21
- data/assets/dispatcher +59 -13
- data/install.md +6 -1
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/cli.rb +19 -9
- 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 +6 -3
- 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/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/team.rb +5 -4
- data/lib/maf/team_command.rb +3 -1
- data/lib/maf/untrack.rb +7 -0
- 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 +4 -1
|
@@ -8,15 +8,17 @@ module Maf
|
|
|
8
8
|
# Launcher replaces this process with the interactive session of a harness.
|
|
9
9
|
# The harness file builds the command (Maf::Shared::Harnesses).
|
|
10
10
|
module Launcher
|
|
11
|
-
def self.launch(harness, role, model)
|
|
11
|
+
def self.launch(harness, role, model, effort = nil)
|
|
12
12
|
ENV.update(harness.launch_env)
|
|
13
|
-
return exec_or_die(harness.launch(role, model)) if Maf::Shared::Sandbox.mode == "off"
|
|
13
|
+
return exec_or_die(harness.launch(role, model, effort)) if Maf::Shared::Sandbox.mode == "off"
|
|
14
14
|
|
|
15
15
|
warn "maf: sandbox: #{Maf::Shared::Sandbox.describe(harness)}"
|
|
16
|
-
exit Maf::Shared::Sandbox.keeping_links(harness) { relay_or_die(sandboxed(harness, role, model)) }
|
|
16
|
+
exit Maf::Shared::Sandbox.keeping_links(harness) { relay_or_die(sandboxed(harness, role, model, effort)) }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def self.sandboxed(harness, role, model
|
|
19
|
+
def self.sandboxed(harness, role, model, effort)
|
|
20
|
+
Maf::Shared::Sandbox.wrap(harness.launch(role, model, effort), harness)
|
|
21
|
+
end
|
|
20
22
|
|
|
21
23
|
def self.exec_or_die(cmd)
|
|
22
24
|
Kernel.exec(*cmd)
|
|
@@ -26,10 +26,16 @@ module Maf
|
|
|
26
26
|
# maf add keeps the current agents, so the hint names only the missing agent.
|
|
27
27
|
def add_hint(missing) = " Fix: maf add #{missing}"
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
# The saved model and effort of the agent, unless args already name them.
|
|
30
|
+
def fill(args)
|
|
31
|
+
verify!(args.harness, args.role)
|
|
32
|
+
saved = entry(args.harness, args.role)
|
|
33
|
+
args.model ||= saved["model"]
|
|
34
|
+
args.effort ||= saved["effort"]
|
|
35
|
+
args
|
|
32
36
|
end
|
|
37
|
+
|
|
38
|
+
def entry(harness, role) = @agents.find { |a| a["harness"] == harness && a["role"] == role }
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
end
|
data/lib/maf/setup_agent.rb
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# setup_agent.rb - create a worktree for one agent and launch its harness session.
|
|
4
4
|
#
|
|
5
|
-
# Usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL]
|
|
5
|
+
# Usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--effort LEVEL]
|
|
6
6
|
# [--dispatch [DISPATCHER FLAGS...]]
|
|
7
7
|
# maf start claude backend-developer_1
|
|
8
8
|
# maf start opencode reviewer --model openrouter/deepseek-v3
|
|
9
|
+
# maf start claude architect --effort high
|
|
9
10
|
# maf start hermes tester --dispatch --model openrouter/deepseek-v3
|
|
10
11
|
# maf start claude reviewer --dispatch --cache-window 1500
|
|
11
12
|
#
|
|
@@ -36,11 +37,10 @@ module Maf
|
|
|
36
37
|
MANIFEST = ".maf/config.json"
|
|
37
38
|
|
|
38
39
|
def self.run(argv)
|
|
39
|
-
args = Args.parse(argv)
|
|
40
|
-
manifest = Manifest.load(MANIFEST).tap { |m| m.verify!(args.harness, args.role) }
|
|
40
|
+
args = Manifest.load(MANIFEST).fill(Args.parse(argv))
|
|
41
41
|
worktree = enter_worktree(args)
|
|
42
|
-
register(args
|
|
43
|
-
launch(args,
|
|
42
|
+
register(args)
|
|
43
|
+
launch(args, worktree)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# `maf start` without arguments, inside a worktree that `maf prepare` made.
|
|
@@ -56,12 +56,13 @@ module Maf
|
|
|
56
56
|
def self.start_args(entry)
|
|
57
57
|
args = [entry["harness"], "#{entry["role"]}_#{entry["worker_id"]}"]
|
|
58
58
|
args += ["--model", entry["model"]] if entry["model"]
|
|
59
|
+
args += ["--effort", entry["effort"]] if entry["effort"]
|
|
59
60
|
entry["dispatch"] ? args + ["--dispatch"] : args
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
def self.register(args
|
|
63
|
+
def self.register(args)
|
|
63
64
|
entry = { "role" => args.role, "worker_id" => args.worker_id, "harness" => args.harness,
|
|
64
|
-
"model" => args.model
|
|
65
|
+
"model" => args.model, "effort" => args.effort, "dispatch" => args.dispatch, "dir" => Dir.pwd }
|
|
65
66
|
Maf::Workers.at(Project.root).add(args.worker, entry.compact)
|
|
66
67
|
end
|
|
67
68
|
|
|
@@ -79,13 +80,12 @@ module Maf
|
|
|
79
80
|
worktree
|
|
80
81
|
end
|
|
81
82
|
|
|
82
|
-
def self.launch(args,
|
|
83
|
+
def self.launch(args, _worktree)
|
|
83
84
|
identify(args)
|
|
84
|
-
|
|
85
|
-
return Dispatch.launch(args, model) if args.dispatch
|
|
85
|
+
return Dispatch.launch(args) if args.dispatch
|
|
86
86
|
|
|
87
87
|
MafSession.register(Project.root, args.harness)
|
|
88
|
-
Launcher.launch(Maf::Shared::Harness.fetch(args.harness), args.role, model)
|
|
88
|
+
Launcher.launch(Maf::Shared::Harness.fetch(args.harness), args.role, args.model, args.effort)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
# The session and the dispatcher pass the environment to each child, so
|
|
@@ -24,9 +24,9 @@ module Maf
|
|
|
24
24
|
# files outside the project reads its folder there.
|
|
25
25
|
def role_destination(project, role, _settings = {}) = role_path(role) && File.join(project, role_path(role))
|
|
26
26
|
|
|
27
|
-
# An ERB template. It gets role, title, description, can_edit, project, prompt, and
|
|
27
|
+
# An ERB template. It gets role, title, description, can_edit, project, prompt, model, and effort.
|
|
28
28
|
def role_template = raise(NotImplementedError, "#{name}: role_template")
|
|
29
|
-
def render_role(values) = ERB.new(role_template, trim_mode: "-").result_with_hash(**values)
|
|
29
|
+
def render_role(values) = ERB.new(role_template, trim_mode: "-").result_with_hash(effort: nil, **values)
|
|
30
30
|
|
|
31
31
|
# nil: the harness lists no models, so every name counts as known.
|
|
32
32
|
def models = nil
|
|
@@ -37,6 +37,12 @@ module Maf
|
|
|
37
37
|
# The command that sets the model, for a harness whose role file cannot set it.
|
|
38
38
|
def model_hint(_model) = nil
|
|
39
39
|
|
|
40
|
+
# nil: the harness lists no effort levels, so every level counts as known.
|
|
41
|
+
def efforts(_model = nil) = nil
|
|
42
|
+
def known_effort?(effort, model = nil) = efforts(model).nil? || efforts(model).include?(effort)
|
|
43
|
+
# true: the model runs only with a level.
|
|
44
|
+
def effort_required?(_model) = false
|
|
45
|
+
|
|
40
46
|
# How an idle agent waits for work. :watch - a watcher wakes the idle
|
|
41
47
|
# session. :await - the stop hook waits after `coord await`.
|
|
42
48
|
# :poll - the agent blocks in `coord next --wait`.
|
|
@@ -96,7 +102,7 @@ module Maf
|
|
|
96
102
|
# Run: maf start (an interactive session) and the dispatcher (one run per task).
|
|
97
103
|
module Run
|
|
98
104
|
# The command of an interactive session.
|
|
99
|
-
def launch(_role, _model) = raise(NotImplementedError, "#{name}: launch")
|
|
105
|
+
def launch(_role, _model, _effort = nil) = raise(NotImplementedError, "#{name}: launch")
|
|
100
106
|
def launch_env = {}
|
|
101
107
|
|
|
102
108
|
def sandbox_dirs = []
|
|
@@ -133,6 +139,7 @@ module Maf
|
|
|
133
139
|
|
|
134
140
|
def present(value) = value.to_s.empty? ? nil : value
|
|
135
141
|
def model_flag(model, flag = "--model") = model ? [flag, model] : []
|
|
142
|
+
def effort_flag(effort, flag = "--effort") = effort ? [flag, effort] : []
|
|
136
143
|
def resume(session_id, *flag) = session_id ? [*flag, session_id] : []
|
|
137
144
|
end
|
|
138
145
|
end
|
|
@@ -44,6 +44,8 @@ module Maf
|
|
|
44
44
|
def models = MODELS
|
|
45
45
|
def known_model?(model, _list = nil) = model.match?(MODEL_NAME)
|
|
46
46
|
def default_model = "claude-opus-5-5"
|
|
47
|
+
EFFORTS = %w[low medium high xhigh max].freeze
|
|
48
|
+
def efforts(_model = nil) = EFFORTS
|
|
47
49
|
|
|
48
50
|
def wait = :watch
|
|
49
51
|
def vm_install = "curl -fsSL https://claude.ai/install.sh | bash"
|
|
@@ -91,10 +93,11 @@ module Maf
|
|
|
91
93
|
|
|
92
94
|
# --settings adds the hooks of the flow, --mcp-config adds the graph server.
|
|
93
95
|
# --mcp-config takes many values, so `--` ends the options before the prompt.
|
|
94
|
-
def launch(role, model)
|
|
96
|
+
def launch(role, model, effort = nil)
|
|
95
97
|
prompt = "Read #{role_path(role)} and follow it exactly. Start your work loop now."
|
|
96
98
|
["claude", *flag_if("--settings", flow_file("claude", "settings.json")),
|
|
97
|
-
*flag_if("--mcp-config", flow_file("mcp", "claude.json")), *model_flag(model),
|
|
99
|
+
*flag_if("--mcp-config", flow_file("mcp", "claude.json")), *model_flag(model), *effort_flag(effort),
|
|
100
|
+
"--", prompt]
|
|
98
101
|
end
|
|
99
102
|
|
|
100
103
|
# `claude -p` prints one JSON object with session_id and result.
|
|
@@ -104,7 +107,7 @@ module Maf
|
|
|
104
107
|
|
|
105
108
|
def build_command(config, prompt, session_id)
|
|
106
109
|
cmd = %w[claude -p --output-format json --permission-mode bypassPermissions --agent] + [config.role]
|
|
107
|
-
cmd += model_flag(config.model)
|
|
110
|
+
cmd += model_flag(config.model) + effort_flag(config.effort)
|
|
108
111
|
cmd += LEAN if config.lean
|
|
109
112
|
cmd + flag_if("--mcp-config", run_file(config, "mcp", "claude.json")) +
|
|
110
113
|
resume(session_id, "--resume") + ["--", prompt]
|
|
@@ -52,12 +52,10 @@ module Maf
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Codex keeps the model list of the account in a cache file. Hidden models are internal.
|
|
55
|
-
def models
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
nil
|
|
60
|
-
end
|
|
55
|
+
def models = CodexModels.listed(home)
|
|
56
|
+
# Without a pinned model, Codex runs the model of its config file.
|
|
57
|
+
def efforts(model = nil) = CodexModels.efforts(model || CodexModels.configured(home), home)
|
|
58
|
+
def effort_flag(effort) = effort ? ["-c", "model_reasoning_effort=#{effort.to_s.dump}"] : []
|
|
61
59
|
|
|
62
60
|
# Codex keeps MCP servers in a global user file. maf does not edit it.
|
|
63
61
|
def mcp_command(server, vault) = "codex mcp add #{server} -- ruby #{vault} mcp"
|
|
@@ -79,12 +77,20 @@ module Maf
|
|
|
79
77
|
tracked.call(role_dir) + hooks
|
|
80
78
|
end
|
|
81
79
|
|
|
82
|
-
def launch(role, model)
|
|
80
|
+
def launch(role, model, effort = nil)
|
|
83
81
|
abort "setup_agent: #{role_path(role)} missing." unless File.exist?(role_path(role))
|
|
84
82
|
|
|
85
83
|
warn_untrusted
|
|
86
|
-
["codex", "--add-dir", File.join(Project.root, ".maf", "coordination"), *model_flag(model),
|
|
87
|
-
File.read(role_path(role))]
|
|
84
|
+
["codex", *no_daemon, "--add-dir", File.join(Project.root, ".maf", "coordination"), *model_flag(model),
|
|
85
|
+
*effort_flag(effort), File.read(role_path(role))]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def no_daemon = codex_help.include?("--no-daemon") ? ["--no-daemon"] : []
|
|
89
|
+
|
|
90
|
+
def codex_help
|
|
91
|
+
IO.popen(%w[codex --help], err: File::NULL, &:read)
|
|
92
|
+
rescue SystemCallError
|
|
93
|
+
""
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
UNTRUSTED = "maf: Codex does not trust the hooks in %<file>s yet. Without the hooks, the session " \
|
|
@@ -105,7 +111,7 @@ module Maf
|
|
|
105
111
|
def build_command(config, prompt, session_id)
|
|
106
112
|
cmd = %w[codex exec] + (session_id ? ["resume"] : [])
|
|
107
113
|
cmd += %w[--json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox]
|
|
108
|
-
cmd += model_flag(config.model, "-m")
|
|
114
|
+
cmd += model_flag(config.model, "-m") + effort_flag(config.effort)
|
|
109
115
|
cmd += lean_flags if config.lean
|
|
110
116
|
cmd + resume(session_id) + [first_prompt(config.role, prompt, session_id)]
|
|
111
117
|
end
|
|
@@ -133,6 +139,29 @@ module Maf
|
|
|
133
139
|
end
|
|
134
140
|
end
|
|
135
141
|
|
|
142
|
+
# CodexModels reads the model list of the account from the cache file of Codex.
|
|
143
|
+
module CodexModels
|
|
144
|
+
def self.listed(home) = cache(home)&.select { |m| m["visibility"] == "list" }&.map { |m| m["slug"] }
|
|
145
|
+
|
|
146
|
+
# nil: no model, or a model outside the cache.
|
|
147
|
+
def self.efforts(model, home)
|
|
148
|
+
entry = model && cache(home)&.find { |m| m["slug"] == model }
|
|
149
|
+
entry&.fetch("supported_reasoning_levels", nil)&.map { |level| level["effort"] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.configured(home)
|
|
153
|
+
path = File.join(home, "config.toml")
|
|
154
|
+
top = File.exist?(path) ? File.read(path).split(/^\[/, 2).first.to_s : ""
|
|
155
|
+
top[/^model\s*=\s*"([^"]+)"/, 1]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.cache(home)
|
|
159
|
+
JSON.parse(File.read(File.join(home, "models_cache.json"))).fetch("models")
|
|
160
|
+
rescue SystemCallError, JSON::ParserError, KeyError, NoMethodError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
136
165
|
# CodexLean drops the parts of the user's Codex setup that a one-shot run
|
|
137
166
|
# does not use. Each one adds tool schemas, skill lists, or MCP calls
|
|
138
167
|
# to every model call. Only the graphify server of this project stays.
|
|
@@ -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
|
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,
|
|
@@ -72,7 +72,7 @@ module Maf
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
def prepare
|
|
75
|
-
Maf.flow("--agent", spec) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
|
|
75
|
+
Maf.flow("--agent", spec, *effort_flag) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
|
|
76
76
|
worktree = SetupAgent::Worktree.ensure(@args.role, @args.worker_id, @args.harness)
|
|
77
77
|
SetupAgent::RoleFile.copy(@root, worktree.dir, @args.harness, @args.role)
|
|
78
78
|
archive_on_harness_change
|
|
@@ -87,6 +87,7 @@ module Maf
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def spec = [@args.harness, @args.role, @args.model].compact.join(":")
|
|
90
|
+
def effort_flag = @args.effort ? ["--effort", "#{@args.role}=#{@args.effort}"] : []
|
|
90
91
|
|
|
91
92
|
def workers_after
|
|
92
93
|
all = Workers.at(@root).all
|
|
@@ -98,12 +99,12 @@ module Maf
|
|
|
98
99
|
def start_dispatcher
|
|
99
100
|
Dir.chdir(@root)
|
|
100
101
|
args = [@args.harness, "#{@args.role}_#{@args.worker_id}", "--dispatch", "--detach"]
|
|
101
|
-
SetupAgent.run(
|
|
102
|
+
SetupAgent.run(args + { "--model" => @args.model, "--effort" => @args.effort }.compact.flatten)
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
def entry(dir)
|
|
105
106
|
{ "role" => @args.role, "worker_id" => @args.worker_id, "harness" => @args.harness,
|
|
106
|
-
"model" => @args.model, "dispatch" => @args.dispatch, "dir" => dir }.compact
|
|
107
|
+
"model" => @args.model, "effort" => @args.effort, "dispatch" => @args.dispatch, "dir" => dir }.compact
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
def print_next_steps
|
data/lib/maf/team_command.rb
CHANGED
|
@@ -32,10 +32,12 @@ module Maf
|
|
|
32
32
|
|
|
33
33
|
def line(id, entry)
|
|
34
34
|
mode = entry["dispatch"] ? "dispatch" : "interactive"
|
|
35
|
-
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry
|
|
35
|
+
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], model(entry), mode, state(id, entry)]
|
|
36
36
|
.join(" ")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def model(entry) = [entry["model"] || "-", entry["effort"]].compact.join("/")
|
|
40
|
+
|
|
39
41
|
def state(id, entry)
|
|
40
42
|
pid = entry["pid"]
|
|
41
43
|
return "-" unless pid
|
data/lib/maf/untrack.rb
CHANGED
|
@@ -18,6 +18,11 @@ 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
|
+
|
|
21
26
|
def run
|
|
22
27
|
steps = plan
|
|
23
28
|
return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
|
|
@@ -58,6 +63,8 @@ module Maf
|
|
|
58
63
|
$stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
|
|
59
64
|
end
|
|
60
65
|
|
|
66
|
+
public
|
|
67
|
+
|
|
61
68
|
# The tracked files of .maf/, and the tracked paths of each harness (Harness#untrack_paths).
|
|
62
69
|
def tracked
|
|
63
70
|
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
|