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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +22 -1
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/coord +145 -21
  6. data/assets/dispatcher +67 -13
  7. data/install.md +6 -1
  8. data/lib/maf/backup.rb +63 -0
  9. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  10. data/lib/maf/bootstrap.rb +4 -0
  11. data/lib/maf/budget.rb +2 -0
  12. data/lib/maf/cli.rb +38 -10
  13. data/lib/maf/flow/effort_report.rb +20 -0
  14. data/lib/maf/flow/generator.rb +2 -1
  15. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  16. data/lib/maf/flow/manifest.rb +1 -1
  17. data/lib/maf/flow/models.rb +11 -0
  18. data/lib/maf/flow/options.rb +29 -3
  19. data/lib/maf/flow/prompt_text.rb +14 -7
  20. data/lib/maf/flow/report.rb +6 -5
  21. data/lib/maf/flow/role_catalog.rb +1 -1
  22. data/lib/maf/flow/role_files.rb +5 -5
  23. data/lib/maf/flow/roster.rb +6 -3
  24. data/lib/maf/flow.rb +3 -1
  25. data/lib/maf/menu.rb +48 -14
  26. data/lib/maf/restore.rb +62 -0
  27. data/lib/maf/setup_agent/args.rb +16 -10
  28. data/lib/maf/setup_agent/dispatch.rb +2 -2
  29. data/lib/maf/setup_agent/launcher.rb +6 -4
  30. data/lib/maf/setup_agent/manifest.rb +9 -3
  31. data/lib/maf/setup_agent.rb +11 -11
  32. data/lib/maf/shared/harness/defaults.rb +10 -3
  33. data/lib/maf/shared/harnesses/claude.rb +6 -3
  34. data/lib/maf/shared/harnesses/codex.rb +39 -10
  35. data/lib/maf/shared/harnesses/cursor.rb +60 -4
  36. data/lib/maf/shared/harnesses/hermes.rb +21 -11
  37. data/lib/maf/shared/harnesses/opencode.rb +25 -2
  38. data/lib/maf/shared/roles.rb +4 -0
  39. data/lib/maf/shared/sandbox/seatbelt.rb +5 -1
  40. data/lib/maf/shared/sandbox.rb +6 -0
  41. data/lib/maf/team.rb +15 -6
  42. data/lib/maf/team_command.rb +11 -2
  43. data/lib/maf/untrack.rb +12 -1
  44. data/lib/maf/version.rb +1 -1
  45. data/lib/maf/worker_control.rb +12 -1
  46. data/lib/maf/worker_list.rb +83 -0
  47. data/templates/roles.yml +14 -0
  48. metadata +6 -1
@@ -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. Keep the claim, so another worker
103
- does not hit the same blocker. Annotate the blocker and the missing parts.
104
- Message the architect. Stop. Do not retry a failing approach.
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
- - If the user gives you a team budget, record it: `maf team set --max <n> --allow <harness[:model]> ...`.
213
- Then manage the team yourself in dispatch mode. `maf prepare ... --dispatch` starts the worker in the background.
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. Do not ask the user to run setup steps.
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>`.
@@ -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 print_sessions]
16
- .each { |name| send(name, results) }
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 hook steps print at install time, far above the last lines the user
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 hook is not active yet. Finish the steps above.",
28
- "Then run `maf update` to confirm the hook is ready."
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
@@ -20,14 +20,14 @@ module Maf
20
20
 
21
21
  def generate_agent(agent)
22
22
  harness = Harness.fetch(agent[:harness])
23
- model = @options.model_for(agent)
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]), model)
26
- { agent: agent, dest: dest, status: write(dest, content), model: model }
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, model)
30
- harness.render_role(**template_values(role, data), prompt: @prompts.build(harness, role, data), model: model)
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)
@@ -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: a[:saved_model]) || a }
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 { |a| { harness: a["harness"], role: a["role"], saved_model: a["model"] } }
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| ["--agent", agent_spec(harness, role, models)] })
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 agent_spec(harness, role, models)
59
+ def agent_args(harness, role, models)
60
60
  model = ask_model(harness, role, models).to_s
61
- [harness, role, model].reject(&:empty?).join(":")
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
- # The answer is a number of the list, a model name, or Enter for the default.
65
- # A name that the harness does not list needs a confirmation: a typo makes
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
- answer = @prompt.ask("Model for #{role} (number or name, Enter = #{default_label(harness)}):").to_s
69
- number = Integer(answer, exception: false)
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}?")
@@ -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
@@ -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 and --model M are setup_agent's own flags; all
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, :dispatch, :detach, :dispatcher_args,
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 = take_model(flags)
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.take_model(flags)
35
- index = flags.index("--model")
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: --model needs a value" if index && (value.nil? || value.start_with?("--"))
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] [--dispatch [FLAGS...]]"
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, model)
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 += ["--model", model] if model
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) = Maf::Shared::Sandbox.wrap(harness.launch(role, model), harness)
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
- def model_for(harness, role)
30
- entry = @agents.find { |a| a["harness"] == harness && a["role"] == role }
31
- entry && entry["model"]
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
@@ -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, manifest.model_for(args.harness, args.role))
43
- launch(args, manifest, worktree)
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, saved_model)
63
+ def self.register(args)
63
64
  entry = { "role" => args.role, "worker_id" => args.worker_id, "harness" => args.harness,
64
- "model" => args.model || saved_model, "dispatch" => args.dispatch, "dir" => Dir.pwd }
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, manifest, _worktree)
83
+ def self.launch(args, _worktree)
83
84
  identify(args)
84
- model = args.model || manifest.model_for(args.harness, args.role)
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 model.
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), "--", prompt]
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
- models = JSON.parse(File.read(File.join(Dir.home, ".codex", "models_cache.json"))).fetch("models")
57
- models.select { |m| m["visibility"] == "list" }.map { |m| m["slug"] }
58
- rescue SystemCallError, JSON::ParserError, KeyError, NoMethodError
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.