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
data/lib/maf/flow/prompt_text.rb
CHANGED
|
@@ -99,9 +99,12 @@ module Maf
|
|
|
99
99
|
- Before you continue a claimed task after a pause, run `coord show <id>`. If the status is not pending,
|
|
100
100
|
or the worker is not you, stop work on that task. `coord reap` releases the claims of stalled workers.
|
|
101
101
|
- Finish the whole task. Report done only when each acceptance criterion passes.
|
|
102
|
-
- If you cannot finish, do the parts you can
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
- If you cannot finish, do the parts you can and commit them on the task branch.
|
|
103
|
+
Then block the task: `coord block <id> --kind <kind> "<blocker and missing parts>"`.
|
|
104
|
+
Kinds: needs_input (a decision of the user), dependency (other work must land first),
|
|
105
|
+
capability (a missing tool, access, or model). coord tells the role that can remove the blocker.
|
|
106
|
+
A blocked task leaves `coord next`, so no other worker hits the blocker.
|
|
107
|
+
Stop. Do not retry a failing approach.
|
|
105
108
|
%{no_task_instruction}
|
|
106
109
|
- Record durable knowledge in the shared vault or #{DECISIONS}.
|
|
107
110
|
- Never write ad-hoc verification scripts. The test suite is the verification.
|
|
@@ -209,13 +212,16 @@ module Maf
|
|
|
209
212
|
# run the team. Dispatched workers cost no tokens while idle, so the rules
|
|
210
213
|
# scale on backlog, not on cost.
|
|
211
214
|
TEAM_RULES = <<~TEXT.strip
|
|
212
|
-
-
|
|
213
|
-
|
|
215
|
+
- Only the user sets the team budget: `maf team set --max <n> --allow <harness[:model]> ...`.
|
|
216
|
+
If the user gives you a budget in words, give the user that command.
|
|
217
|
+
With a budget, manage the team yourself in dispatch mode. `maf prepare ... --dispatch` starts the worker in the background.
|
|
218
|
+
In the macOS sandbox, maf cannot start it. Give the user the two commands that it prints.
|
|
214
219
|
- Check the team with `maf team`. It shows the budget, each worker, and the tasks by role.
|
|
215
220
|
- Staff the architect first, with `--dispatch`. A goal needs the architect before any other worker.
|
|
216
221
|
A dispatched architect starts on each message. An idle interactive architect reads nothing.
|
|
217
222
|
- Check who runs with `coord who`. A role without a live worker does not read its messages.
|
|
218
|
-
- If coord reports "No worker runs role <role>", add a worker for that role.
|
|
223
|
+
- If coord reports "No worker runs role <role>", add a worker for that role if the budget has a free slot.
|
|
224
|
+
Without a budget, tell the user.
|
|
219
225
|
- If a role has more than three backlog tasks and the budget has a free slot, add a worker for that role.
|
|
220
226
|
- If a role has no tasks and no open goal needs it, retire its extra workers.
|
|
221
227
|
Keep one worker per role that an open goal needs.
|
|
@@ -239,7 +245,8 @@ module Maf
|
|
|
239
245
|
|
|
240
246
|
Rules:
|
|
241
247
|
- Never edit source files. Never create tasks; only the architect creates tasks.
|
|
242
|
-
- Change the team when the user asks
|
|
248
|
+
- Change the team only when the user asks, or within the team budget. Without a budget, `maf prepare` refuses.
|
|
249
|
+
Then give the user the command, or ask the user for a budget.
|
|
243
250
|
Add a worker: `maf prepare <harness> <role>_<n>`.
|
|
244
251
|
Replace a worker: `maf prepare <harness> <role>_<n> --replace <old-role>_<n>`.
|
|
245
252
|
Remove a worker: `maf retire <role>_<n>`.
|
data/lib/maf/flow/report.rb
CHANGED
|
@@ -12,20 +12,21 @@ module Maf
|
|
|
12
12
|
|
|
13
13
|
def print(results)
|
|
14
14
|
puts "", "Generated role files:", results.map { |result| generated_line(result) }
|
|
15
|
-
%i[print_missing_models print_unknown_models print_unembeddable
|
|
16
|
-
|
|
15
|
+
%i[print_missing_models print_unknown_models print_unembeddable].each { |name| send(name, results) }
|
|
16
|
+
EffortReport.print(results)
|
|
17
|
+
print_sessions(results)
|
|
17
18
|
print_hermes_reminder
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
22
|
-
# The
|
|
23
|
+
# The Hermes steps print at install time, far above the last lines the user
|
|
23
24
|
# reads. Repeat the state here so the flow is not started with a dead hook.
|
|
24
25
|
def print_hermes_reminder
|
|
25
26
|
return unless @hermes_pending
|
|
26
27
|
|
|
27
|
-
puts "The Hermes
|
|
28
|
-
"Then run `maf update` to confirm
|
|
28
|
+
puts "The Hermes setup is not done yet. Finish the steps above.",
|
|
29
|
+
"Then run `maf update` to confirm that Hermes is ready."
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def generated_line(result)
|
|
@@ -6,7 +6,7 @@ module Maf
|
|
|
6
6
|
# role in .maf/roles.yml adds a role or replaces a built-in role.
|
|
7
7
|
class RoleCatalog
|
|
8
8
|
FILE = ".maf/roles.yml"
|
|
9
|
-
DEFAULTS = { "model_hint" => "", "can_edit" => true }.freeze
|
|
9
|
+
DEFAULTS = { "model_hint" => "", "can_edit" => true, "effort" => nil }.freeze
|
|
10
10
|
|
|
11
11
|
def initialize(project)
|
|
12
12
|
@project = project
|
data/lib/maf/flow/role_files.rb
CHANGED
|
@@ -20,14 +20,14 @@ module Maf
|
|
|
20
20
|
|
|
21
21
|
def generate_agent(agent)
|
|
22
22
|
harness = Harness.fetch(agent[:harness])
|
|
23
|
-
|
|
23
|
+
choice = { model: @options.model_for(agent), effort: @options.effort_for(agent) }
|
|
24
24
|
dest = harness.role_destination(@options.project, agent[:role], @options.settings)
|
|
25
|
-
content = render(harness, agent[:role], @roles.fetch(agent[:role]),
|
|
26
|
-
{ agent: agent, dest: dest, status: write(dest, content),
|
|
25
|
+
content = render(harness, agent[:role], @roles.fetch(agent[:role]), choice)
|
|
26
|
+
{ agent: agent, dest: dest, status: write(dest, content), **choice }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def render(harness, role, data,
|
|
30
|
-
harness.render_role(**template_values(role, data), prompt: @prompts.build(harness, role, data),
|
|
29
|
+
def render(harness, role, data, choice)
|
|
30
|
+
harness.render_role(**template_values(role, data), prompt: @prompts.build(harness, role, data), **choice)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def template_values(role, data)
|
data/lib/maf/flow/roster.rb
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Maf
|
|
4
4
|
module Flow
|
|
5
5
|
# Roster merges the agents saved in .maf/config.json with the --agent and
|
|
6
|
-
# --remove specs of this run. A saved model ranks below --model
|
|
6
|
+
# --remove specs of this run. A saved model ranks below --model, a saved
|
|
7
|
+
# effort below --effort.
|
|
7
8
|
class Roster
|
|
8
9
|
def initialize(project)
|
|
9
10
|
path = File.join(project, ".maf/config.json")
|
|
@@ -11,7 +12,7 @@ module Maf
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def merge(added, removed)
|
|
14
|
-
kept = saved.map { |a| added.find { |b| same?(a, b) }&.merge(saved_model
|
|
15
|
+
kept = saved.map { |a| added.find { |b| same?(a, b) }&.merge(a.slice(:saved_model, :saved_effort)) || a }
|
|
15
16
|
agents = kept + added.reject { |b| kept.any? { |a| same?(a, b) } }
|
|
16
17
|
agents.reject { |a| removed.any? { |b| same?(a, b) } }
|
|
17
18
|
end
|
|
@@ -19,7 +20,9 @@ module Maf
|
|
|
19
20
|
private
|
|
20
21
|
|
|
21
22
|
def saved
|
|
22
|
-
@saved.map
|
|
23
|
+
@saved.map do |a|
|
|
24
|
+
{ harness: a["harness"], role: a["role"], saved_model: a["model"], saved_effort: a["effort"] }
|
|
25
|
+
end
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def same?(one, other) = one[:harness] == other[:harness] && one[:role] == other[:role]
|
data/lib/maf/flow.rb
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
#
|
|
5
5
|
# maf add, maf remove, maf update, and maf roles run Flow::Generator.
|
|
6
6
|
# Options: --project DIR [--agent HARNESS:ROLE ...] [--remove HARNESS:ROLE ...]
|
|
7
|
-
# [--model ROLE=MODEL] [--hermes-dir DIR] [--check] [--force] [--no-bootstrap]
|
|
7
|
+
# [--model ROLE=MODEL] [--effort ROLE=LEVEL] [--hermes-dir DIR] [--check] [--force] [--no-bootstrap]
|
|
8
8
|
# [--list-roles]
|
|
9
9
|
#
|
|
10
10
|
# HARNESS is a harness of the registry: one file in lib/maf/shared/harnesses/.
|
|
@@ -52,6 +52,7 @@ require_relative "flow/options"
|
|
|
52
52
|
require_relative "flow/prompt_builder"
|
|
53
53
|
require_relative "flow/hook_files"
|
|
54
54
|
require_relative "flow/hermes_hook_setup"
|
|
55
|
+
require_relative "flow/hermes_env_setup"
|
|
55
56
|
require_relative "flow/codex_hooks"
|
|
56
57
|
require_relative "flow/legacy_codex_hook"
|
|
57
58
|
require_relative "flow/hook_installer"
|
|
@@ -61,6 +62,7 @@ require_relative "flow/mcp_installer"
|
|
|
61
62
|
require_relative "flow/role_files"
|
|
62
63
|
require_relative "flow/manifest"
|
|
63
64
|
require_relative "flow/models"
|
|
65
|
+
require_relative "flow/effort_report"
|
|
64
66
|
require_relative "flow/report"
|
|
65
67
|
require_relative "flow/validator"
|
|
66
68
|
require_relative "flow/harness_linker"
|
data/lib/maf/menu.rb
CHANGED
|
@@ -21,14 +21,14 @@ module Maf
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# Only the installed harnesses are choices. Each role gets its own model.
|
|
24
|
+
# Only the installed harnesses are choices. Each role gets its own model and effort.
|
|
25
25
|
def add
|
|
26
26
|
harness = @prompt.choose("Harness", Flow::Models.installed) or return
|
|
27
27
|
roles = @prompt.choose_many("Roles", Maf.role_names)
|
|
28
28
|
return if roles.empty?
|
|
29
29
|
|
|
30
30
|
models = show_models(harness)
|
|
31
|
-
Maf.flow(*roles.flat_map { |role|
|
|
31
|
+
Maf.flow(*roles.flat_map { |role| agent_args(harness, role, models) })
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
private
|
|
@@ -56,21 +56,18 @@ module Maf
|
|
|
56
56
|
@prompt.say("error: #{e.message}")
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def agent_args(harness, role, models)
|
|
60
60
|
model = ask_model(harness, role, models).to_s
|
|
61
|
-
|
|
61
|
+
effort = ask_effort(harness, role, model.empty? ? nil : model).to_s
|
|
62
|
+
flags = effort.empty? ? [] : ["--effort", "#{role}=#{effort}"]
|
|
63
|
+
["--agent", [harness, role, model].reject(&:empty?).join(":"), *flags]
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# every dispatched run fail.
|
|
66
|
+
# A model name that the harness does not list needs a confirmation: a typo
|
|
67
|
+
# makes every dispatched run fail.
|
|
67
68
|
def ask_model(harness, role, models)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return models[number - 1] if number&.between?(1, models.size)
|
|
71
|
-
return answer if answer.empty? || Flow::Models.known?(harness, answer)
|
|
72
|
-
|
|
73
|
-
@prompt.confirm?("#{harness} does not list #{answer}. Use it anyway?") ? answer : ask_model(harness, role, models)
|
|
69
|
+
question = "Model for #{role} (number or name, Enter = #{default_label(harness)}):"
|
|
70
|
+
ask_listed(harness, question, models) { |name| Flow::Models.known?(harness, name) }
|
|
74
71
|
end
|
|
75
72
|
|
|
76
73
|
def default_label(harness) = Flow::Models.default(harness) || "the harness default"
|
|
@@ -78,10 +75,47 @@ module Maf
|
|
|
78
75
|
def show_models(harness)
|
|
79
76
|
models = Flow::Models.for(harness).to_a
|
|
80
77
|
@prompt.say(models.empty? ? "#{harness} lists no models. Type a name." : "Models of #{harness}:")
|
|
81
|
-
models.each_with_index { |model, i| @prompt.say(" #{i + 1}) #{model}") }
|
|
78
|
+
models.each_with_index { |model, i| @prompt.say(" #{i + 1}) #{model}#{levels_label(harness, model)}") }
|
|
82
79
|
models
|
|
83
80
|
end
|
|
84
81
|
|
|
82
|
+
def levels_label(harness, model)
|
|
83
|
+
levels = Flow::Models.efforts(harness, model).to_a
|
|
84
|
+
levels.empty? ? "" : " (effort: #{levels.join(", ")})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# An empty list: the model takes no level, so maf does not ask.
|
|
88
|
+
# nil: the harness lists no levels, so every level counts as known.
|
|
89
|
+
def ask_effort(harness, role, model)
|
|
90
|
+
levels = Flow::Models.efforts(harness, model)
|
|
91
|
+
return if levels == []
|
|
92
|
+
|
|
93
|
+
levels.to_a.each_with_index { |level, i| @prompt.say(" #{i + 1}) #{level}") }
|
|
94
|
+
question = "Effort for #{role} (number or name, Enter = #{effort_label(harness, role, model)}):"
|
|
95
|
+
ask_listed(harness, question, levels.to_a) { |name| levels.nil? || levels.include?(name) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def effort_label(harness, role, model)
|
|
99
|
+
Flow::Models.default_effort(harness, Maf.role_effort(role), model) || "the harness default"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The answer is a number of the list, a name, or Enter for the default.
|
|
103
|
+
def ask_listed(harness, question, list, &known)
|
|
104
|
+
answer = @prompt.ask(question).to_s
|
|
105
|
+
picked = pick(answer, list, &known)
|
|
106
|
+
return picked if picked
|
|
107
|
+
return answer if @prompt.confirm?("#{harness} does not list #{answer}. Use it anyway?")
|
|
108
|
+
|
|
109
|
+
ask_listed(harness, question, list, &known)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def pick(answer, list)
|
|
113
|
+
number = Integer(answer, exception: false)
|
|
114
|
+
return list[number - 1] if number&.between?(1, list.size)
|
|
115
|
+
|
|
116
|
+
answer if answer.empty? || yield(answer)
|
|
117
|
+
end
|
|
118
|
+
|
|
85
119
|
def remove
|
|
86
120
|
spec = @prompt.choose("Agent", Maf.agent_specs) or return
|
|
87
121
|
Maf.flow("--remove", spec) if @prompt.confirm?("Remove #{spec}?")
|
data/lib/maf/restore.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# restore.rb - maf restore: put the flow config back from a backup.
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "backup"
|
|
6
|
+
|
|
7
|
+
module Maf
|
|
8
|
+
# Restore copies the files of a backup into .maf/. It keeps a file that
|
|
9
|
+
# exists, unless --force is given. Then it runs maf update.
|
|
10
|
+
class Restore
|
|
11
|
+
USAGE = "usage: maf restore [--list] [BACKUP] [--force]"
|
|
12
|
+
|
|
13
|
+
def initialize(root, argv)
|
|
14
|
+
@root = root
|
|
15
|
+
@backup = Backup.new(root)
|
|
16
|
+
@list = argv.include?("--list")
|
|
17
|
+
@force = argv.include?("--force")
|
|
18
|
+
@name = argv.find { |arg| !arg.start_with?("-") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
return list if @list
|
|
23
|
+
|
|
24
|
+
dir = @name ? @backup.find(@name) : @backup.latest
|
|
25
|
+
abort(@name ? "maf: no backup #{@name}. Run: maf restore --list" : "maf: no backup in #{@root}") unless dir
|
|
26
|
+
restore(dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def list
|
|
32
|
+
return puts("No backup yet. maf makes one when a maf command runs.") if @backup.snapshots.empty?
|
|
33
|
+
|
|
34
|
+
@backup.snapshots.reverse_each { |dir| puts line(dir) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def line(dir)
|
|
38
|
+
files = @backup.read(dir)
|
|
39
|
+
agents = JSON.parse(files.fetch("config.json", "{}")).fetch("agents", []).size
|
|
40
|
+
"#{File.basename(dir)} #{@backup.time(dir)} #{agents} agents #{files.keys.join(", ")}"
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
"#{File.basename(dir)} #{@backup.time(dir)} #{files.keys.join(", ")}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def restore(dir)
|
|
46
|
+
puts "Backup #{File.basename(dir)} (#{@backup.time(dir)}):"
|
|
47
|
+
written = @backup.read(dir).select { |name, text| put(name, text) }
|
|
48
|
+
return puts("Nothing to restore. --force replaces the files that exist.") if written.empty?
|
|
49
|
+
|
|
50
|
+
Dir.chdir(@root) { Maf.flow } if written.key?("config.json")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def put(name, text)
|
|
54
|
+
path = File.join(@root, ".maf", name)
|
|
55
|
+
return puts(" keep .maf/#{name} (exists; --force replaces it)") || false if File.exist?(path) && !@force
|
|
56
|
+
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
58
|
+
File.write(path, text)
|
|
59
|
+
puts(" restore .maf/#{name}") || true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/maf/setup_agent/args.rb
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
module Maf
|
|
4
4
|
module SetupAgent
|
|
5
5
|
# Args reads the positional arguments (HARNESS ROLE[_WORKER] [model:M]),
|
|
6
|
-
# then the flags. --dispatch
|
|
7
|
-
# other flags go to the dispatcher and need --dispatch.
|
|
6
|
+
# then the flags. --dispatch, --model M, and --effort L are setup_agent's own
|
|
7
|
+
# flags; all other flags go to the dispatcher and need --dispatch.
|
|
8
8
|
class Args
|
|
9
|
-
Parsed = Struct.new(:harness, :role, :worker_id, :worker, :model, :
|
|
10
|
-
keyword_init: true)
|
|
9
|
+
Parsed = Struct.new(:harness, :role, :worker_id, :worker, :model, :effort, :dispatch, :detach,
|
|
10
|
+
:dispatcher_args, keyword_init: true)
|
|
11
11
|
|
|
12
12
|
def self.parse(argv)
|
|
13
13
|
dispatch, detach, positional, flags = split(argv)
|
|
14
|
-
model =
|
|
14
|
+
model, effort = %w[--model --effort].map { |flag| take_value(flags, flag) }
|
|
15
|
+
check_flags(flags, dispatch, detach)
|
|
16
|
+
Parsed.new(**identity(positional, model, dispatch), effort: effort, dispatch: dispatch, detach: detach,
|
|
17
|
+
dispatcher_args: flags)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.check_flags(flags, dispatch, detach)
|
|
15
21
|
reject_stray(flags, dispatch)
|
|
16
22
|
abort "setup_agent: --detach works only with --dispatch" if detach && !dispatch
|
|
17
|
-
Parsed.new(**identity(positional, model, dispatch), dispatch: dispatch, detach: detach, dispatcher_args: flags)
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def self.split(argv)
|
|
@@ -31,10 +36,10 @@ module Maf
|
|
|
31
36
|
model: model || model_arg&.sub(/\Amodel:/, "") }
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def self.
|
|
35
|
-
index = flags.index(
|
|
39
|
+
def self.take_value(flags, flag)
|
|
40
|
+
index = flags.index(flag)
|
|
36
41
|
value = index && flags.slice!(index, 2)[1]
|
|
37
|
-
abort "setup_agent:
|
|
42
|
+
abort "setup_agent: #{flag} needs a value" if index && (value.nil? || value.start_with?("--"))
|
|
38
43
|
value
|
|
39
44
|
end
|
|
40
45
|
|
|
@@ -50,7 +55,8 @@ module Maf
|
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
def self.abort_usage
|
|
53
|
-
abort "usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--
|
|
58
|
+
abort "usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--effort LEVEL] " \
|
|
59
|
+
"[--dispatch [FLAGS...]]"
|
|
54
60
|
end
|
|
55
61
|
end
|
|
56
62
|
end
|
|
@@ -9,11 +9,11 @@ module Maf
|
|
|
9
9
|
module Dispatch
|
|
10
10
|
DISPATCHER = ".maf/bin/dispatcher"
|
|
11
11
|
|
|
12
|
-
def self.launch(args
|
|
12
|
+
def self.launch(args)
|
|
13
13
|
dispatcher = File.join(Project.root, DISPATCHER)
|
|
14
14
|
require_dispatcher!(dispatcher)
|
|
15
15
|
cmd = [RbConfig.ruby, dispatcher, args.role, "--harness", args.harness]
|
|
16
|
-
cmd +=
|
|
16
|
+
cmd += { "--model" => args.model, "--effort" => args.effort }.compact.flatten
|
|
17
17
|
cmd += args.dispatcher_args
|
|
18
18
|
args.detach ? detach(cmd, args.worker) : Launcher.exec_or_die(cmd)
|
|
19
19
|
end
|
|
@@ -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.
|