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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +18 -1
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/coord +145 -21
  6. data/assets/dispatcher +59 -13
  7. data/install.md +6 -1
  8. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  9. data/lib/maf/bootstrap.rb +4 -0
  10. data/lib/maf/cli.rb +19 -9
  11. data/lib/maf/flow/effort_report.rb +20 -0
  12. data/lib/maf/flow/generator.rb +2 -1
  13. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  14. data/lib/maf/flow/manifest.rb +1 -1
  15. data/lib/maf/flow/models.rb +11 -0
  16. data/lib/maf/flow/options.rb +29 -3
  17. data/lib/maf/flow/prompt_text.rb +6 -3
  18. data/lib/maf/flow/report.rb +6 -5
  19. data/lib/maf/flow/role_catalog.rb +1 -1
  20. data/lib/maf/flow/role_files.rb +5 -5
  21. data/lib/maf/flow/roster.rb +6 -3
  22. data/lib/maf/flow.rb +3 -1
  23. data/lib/maf/menu.rb +48 -14
  24. data/lib/maf/setup_agent/args.rb +16 -10
  25. data/lib/maf/setup_agent/dispatch.rb +2 -2
  26. data/lib/maf/setup_agent/launcher.rb +6 -4
  27. data/lib/maf/setup_agent/manifest.rb +9 -3
  28. data/lib/maf/setup_agent.rb +11 -11
  29. data/lib/maf/shared/harness/defaults.rb +10 -3
  30. data/lib/maf/shared/harnesses/claude.rb +6 -3
  31. data/lib/maf/shared/harnesses/codex.rb +39 -10
  32. data/lib/maf/shared/harnesses/cursor.rb +60 -4
  33. data/lib/maf/shared/harnesses/hermes.rb +21 -11
  34. data/lib/maf/shared/harnesses/opencode.rb +25 -2
  35. data/lib/maf/shared/roles.rb +4 -0
  36. data/lib/maf/team.rb +5 -4
  37. data/lib/maf/team_command.rb +3 -1
  38. data/lib/maf/untrack.rb +7 -0
  39. data/lib/maf/version.rb +1 -1
  40. data/lib/maf/worker_control.rb +12 -1
  41. data/lib/maf/worker_list.rb +83 -0
  42. data/templates/roles.yml +14 -0
  43. metadata +4 -1
data/install.md CHANGED
@@ -198,7 +198,12 @@ list with the existing entries plus the new entry.
198
198
  The second command approves the hook one time. Hermes stores the consent for this
199
199
  version of the script. A new script version needs a new approval.
200
200
 
201
- Confirm that every check from `hermes hooks doctor` passes. Then continue.
201
+ Confirm that every check from `hermes hooks doctor` passes.
202
+
203
+ Hermes gives the commands of an agent only the variables in
204
+ `terminal.client_env_passthrough`. If `maf add` prints a `hermes config set
205
+ terminal.client_env_passthrough` command, run it. It keeps the names that the
206
+ list has now. Then continue.
202
207
 
203
208
  ## Step 5c - Set the Gemini key for the doc-graph refresh
204
209
 
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Maf
4
4
  module Bootstrap
5
- # ClaudeSettings plans and applies the Claude Code hooks in the settings
6
- # file of the flow, .maf/claude/settings.json. `maf start` passes the file
7
- # with --settings. Claude Code runs these hooks next to the project's own.
5
+ # ClaudeSettings plans and applies the Claude Code hooks and environment in
6
+ # the settings file of the flow, .maf/claude/settings.json. `maf start`
7
+ # passes the file with --settings. Claude Code runs these hooks next to the
8
+ # project's own. The --settings env wins over the env of the user settings.
8
9
  class ClaudeSettings
9
10
  def initialize(project)
10
11
  @project = project
@@ -12,23 +13,30 @@ module Maf
12
13
 
13
14
  def plan
14
15
  file = @project.path(CLAUDE_SETTINGS)
15
- [@project.action(status(file), file, "#{file} (next-task + board-watch hooks)", :claude_stop_hook)]
16
+ [@project.action(status(file), file, "#{file} (next-task + board-watch hooks, env)", :claude_stop_hook)]
16
17
  end
17
18
 
18
19
  def configure(file)
19
20
  settings = read(file)
20
- missing = missing_hooks(settings)
21
- missing.each { |event, hook| add_hook(settings, event, hook) }
22
- write(file, settings) unless missing.empty?
23
- !missing.empty?
21
+ return false if current?(settings)
22
+
23
+ write(file, update(settings))
24
+ true
24
25
  end
25
26
 
26
27
  private
27
28
 
28
29
  def status(file)
29
- File.exist?(file) && missing_hooks(read(file)).empty? ? :skip : :configure_claude_hook
30
+ File.exist?(file) && current?(read(file)) ? :skip : :configure_claude_hook
31
+ end
32
+
33
+ def update(settings)
34
+ missing_hooks(settings).each { |event, hook| add_hook(settings, event, hook) }
35
+ settings.merge("env" => (settings["env"] || {}).merge(CLAUDE_ENV))
30
36
  end
31
37
 
38
+ def current?(settings) = missing_hooks(settings).empty? && (settings["env"] || {}) >= CLAUDE_ENV
39
+
32
40
  def read(file)
33
41
  File.exist?(file) ? (JSON.parse(File.read(file)) rescue {}) : {}
34
42
  end
data/lib/maf/bootstrap.rb CHANGED
@@ -64,6 +64,10 @@ module Maf
64
64
  end
65
65
  ].freeze
66
66
 
67
+ # Claude Code environment of a flow session. Agent teams stay off: with
68
+ # them on, a subagent that Claude names starts as a teammate.
69
+ CLAUDE_ENV = { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" => "0" }.freeze
70
+
67
71
  # The knowledge graph and the Obsidian vault, at the project root where graphify looks by default.
68
72
  GRAPH_DIR = "graphify-out"
69
73
 
data/lib/maf/cli.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "menu"
12
12
  require_relative "team"
13
13
  require_relative "team_command"
14
14
  require_relative "worker_control"
15
+ require_relative "worker_list"
15
16
  require_relative "untrack"
16
17
  require_relative "vm"
17
18
  require_relative "version"
@@ -21,6 +22,7 @@ module Maf
21
22
 
22
23
  def self.flow(*args) = Flow::Generator.new(["--project", Dir.pwd, *args]).run
23
24
  def self.role_names = Flow::RoleCatalog.new(Dir.pwd).roles.keys
25
+ def self.role_effort(role) = Flow::RoleCatalog.new(Dir.pwd).roles.dig(role, "effort")
24
26
 
25
27
  def self.agents = File.exist?(MANIFEST) ? JSON.parse(File.read(MANIFEST)).fetch("agents") : []
26
28
  def self.agent_specs = agents.map { |a| "#{a["harness"]}:#{a["role"]}" }
@@ -38,19 +40,21 @@ module Maf
38
40
 
39
41
  class CLI
40
42
  COMMANDS = {
41
- "add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] add agents and install the flow; " \
42
- "without arguments it asks",
43
+ "add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] [--effort ROLE=LEVEL] add agents and install " \
44
+ "the flow; without arguments it asks",
43
45
  "remove" => "HARNESS:ROLE ... remove agents",
44
46
  "update" => " regenerate the files of the current agents",
45
47
  "agents" => " list the current agents",
46
48
  "roles" => " list the built-in roles and the project roles",
47
49
  "role" => "add NAME add a stub role to .maf/roles.yml",
48
- "start" => "[HARNESS ROLE[_WORKER]] [--dispatch [--detach]] start one agent in its worktree",
49
- "prepare" => "HARNESS ROLE[_WORKER] [--dispatch|--interactive] [--replace W] prepare a worker; " \
50
+ "start" => "[HARNESS ROLE[_WORKER]] [--effort LEVEL] [--dispatch [--detach]] start one agent in its worktree",
51
+ "prepare" => "HARNESS ROLE[_WORKER] [--effort LEVEL] [--dispatch|--interactive] [--replace W] " \
52
+ "prepare a worker; " \
50
53
  "--dispatch starts it (default for the architect)",
51
54
  "retire" => "ROLE[_WORKER] remove a worker; its tasks return to the pool",
52
- "worker" => "status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N] [--max-session-runs N] " \
53
- "[--cache-window S] control one worker; start and restart save the session limits of the role",
55
+ "worker" => "list | status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N] " \
56
+ "[--max-session-runs N] [--cache-window S] list the running workers, or control one worker; " \
57
+ "start and restart save the session limits of the role",
54
58
  "team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
55
59
  "uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
56
60
  "migrate" => "[--check] [--yes] move an old-layout install into .maf/",
@@ -95,7 +99,10 @@ module Maf
95
99
  # Without arguments on a terminal, maf add asks for the harness, the roles, and the models.
96
100
  def run_add = @args.empty? && $stdin.tty? ? Menu.new.add : Maf.flow(*AgentArgs.convert(@args, "--agent"))
97
101
  def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
98
- def run_update = Maf.flow(*@args)
102
+ def run_update
103
+ Maf.flow(*@args)
104
+ Untrack.warn_tracked(SetupAgent::Project.root)
105
+ end
99
106
  def run_roles = Maf.flow("--list-roles")
100
107
 
101
108
  def run_role
@@ -108,13 +115,15 @@ module Maf
108
115
  def run_prepare = Prepare.new(@args).run
109
116
  def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
110
117
  def run_worker
118
+ return WorkerList.new(SetupAgent::Project.root).run if @args.first == "list"
119
+
111
120
  spec = @args[1] || abort(worker_usage)
112
121
  limits = RoleLimits.parse(@args)
113
122
  WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
114
123
  .run(@args[0].to_s)
115
124
  end
116
125
 
117
- def worker_usage = "usage: maf worker #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
126
+ def worker_usage = "usage: maf worker list | #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
118
127
  "[--max-context N] [--max-session-runs N] [--cache-window S]"
119
128
  def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
120
129
  def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
@@ -132,7 +141,8 @@ module Maf
132
141
  end
133
142
 
134
143
  def agent_line(agent)
135
- "#{agent["harness"]}:#{agent["role"]}".ljust(32) + agent["model"].to_s
144
+ effort = agent["effort"] && "effort=#{agent["effort"]}"
145
+ "#{agent["harness"]}:#{agent["role"]}".ljust(32) + [agent["model"], effort].compact.join(" ")
136
146
  end
137
147
  end
138
148
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # EffortReport warns about an effort level that the harness does not list.
6
+ module EffortReport
7
+ def self.print(results)
8
+ list = results.select { |r| r[:effort] && !harness(r).known_effort?(r[:effort], r[:model]) }
9
+ return if list.empty?
10
+
11
+ puts "", "WARNING: the harness does not list these effort levels. Check the level for a typo:",
12
+ list.map { |r| " #{name(r)} -> #{r[:effort]}" },
13
+ " Fix one with: maf add HARNESS:ROLE --effort ROLE=LEVEL"
14
+ end
15
+
16
+ def self.harness(result) = Harness.fetch(result[:agent][:harness])
17
+ def self.name(result) = "#{result[:agent][:harness]}:#{result[:agent][:role]}"
18
+ end
19
+ end
20
+ end
@@ -26,6 +26,7 @@ module Maf
26
26
  Validator.project!(@options)
27
27
  @catalog = RoleCatalog.new(@options.project)
28
28
  @roles = @catalog.roles
29
+ @options.role_efforts = @roles.transform_values { |data| data["effort"] }
29
30
  end
30
31
 
31
32
  def generate_all
@@ -51,7 +52,7 @@ module Maf
51
52
 
52
53
  def print_role(key, data)
53
54
  puts "", " #{key} (#{@catalog.source(key)})", " #{data.fetch("description")}",
54
- " model hint: #{data.fetch("model_hint")}"
55
+ " model hint: #{data.fetch("model_hint")}", *(" effort: #{data["effort"]}" if data["effort"])
55
56
  end
56
57
 
57
58
  def finish_without_agents
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # HermesEnvSetup prints the step that lets Hermes give the flow variables to
6
+ # the commands of an agent. The flow never edits the Hermes config.
7
+ class HermesEnvSetup
8
+ KEY = "terminal.client_env_passthrough"
9
+ NAMES = %w[COORD_DIR COORD_ROLE COORD_WORKER COORD_DISPATCHED COORD_SLOT COORD_SESSION_PID TASKRC
10
+ MAF_BIN].freeze
11
+
12
+ def initialize(config_yaml = File.join(Dir.home, ".hermes", "config.yaml"))
13
+ @config_yaml = config_yaml
14
+ end
15
+
16
+ # Returns true when the user has a step left.
17
+ def install
18
+ names = listed
19
+ return puts(" hermes env ready: #{KEY}") || false if (NAMES - names).empty?
20
+
21
+ print_step(names) || true
22
+ end
23
+
24
+ def command(names) = "hermes config set #{KEY} '#{JSON.generate((names + NAMES).uniq)}'"
25
+
26
+ def listed
27
+ list = config.dig("terminal", "client_env_passthrough")
28
+ list.is_a?(Array) ? list.map(&:to_s) : []
29
+ end
30
+
31
+ private
32
+
33
+ def print_step(names)
34
+ puts "", " Hermes env: the commands of a Hermes agent get no COORD_* variables yet.",
35
+ " Do this step one time. It keeps the names that the list has now.", "", " #{command(names)}", ""
36
+ end
37
+
38
+ def config
39
+ data = YAML.safe_load_file(@config_yaml)
40
+ data.is_a?(Hash) ? data : {}
41
+ rescue SystemCallError, Psych::Exception
42
+ {}
43
+ end
44
+ end
45
+ end
46
+ end
@@ -34,7 +34,7 @@ module Maf
34
34
  # a role without the role definitions.
35
35
  def entry(agent)
36
36
  { harness: agent[:harness], role: agent[:role], model: @options.model_for(agent),
37
- can_edit: @roles.fetch(agent[:role]).fetch("can_edit") }
37
+ can_edit: @roles.fetch(agent[:role]).fetch("can_edit") }.merge({ effort: @options.effort_for(agent) }.compact)
38
38
  end
39
39
 
40
40
  # Other tools own other keys (maf team: "team"; users: "base_branch").
@@ -21,6 +21,17 @@ module Maf
21
21
 
22
22
  def self.known?(harness, model) = Harness[harness]&.known_model?(model, self.for(harness)) != false
23
23
  def self.default(harness) = Harness[harness]&.default_model
24
+ # Without a model, the levels of the default model of the harness.
25
+ def self.efforts(harness, model) = Harness[harness]&.efforts(model || default(harness))
26
+
27
+ # The level of the role if the harness lists it. A model that runs only
28
+ # with a level gets the lowest level instead.
29
+ def self.default_effort(harness, level, model)
30
+ levels = efforts(harness, model).to_a
31
+ return level if levels.include?(level)
32
+
33
+ levels.first if Harness[harness]&.effort_required?(model)
34
+ end
24
35
  end
25
36
  end
26
37
  end
@@ -4,8 +4,8 @@ module Maf
4
4
  module Flow
5
5
  # Options parses the command line and holds the settings of one run.
6
6
  class Options
7
- attr_accessor :project, :agents
8
- attr_reader :removed, :models, :hermes_dir, :list_roles
7
+ attr_accessor :project, :agents, :role_efforts
8
+ attr_reader :removed, :models, :efforts, :hermes_dir, :list_roles
9
9
 
10
10
  # Each flag: the OptionParser arguments, and a block that runs in the
11
11
  # Options instance with the flag value.
@@ -14,6 +14,7 @@ module Maf
14
14
  [["--agent SPEC", "HARNESS:ROLE[:MODEL]"], ->(v) { @agents << parse_agent(v) }],
15
15
  [["--remove SPEC", "HARNESS:ROLE"], ->(v) { @removed << parse_agent(v) }],
16
16
  [["--model PAIR", "ROLE=MODEL"], ->(v) { @models.store(*v.split("=", 2)) }],
17
+ [["--effort PAIR", "ROLE=LEVEL"], ->(v) { @efforts.store(*v.split("=", 2)) }],
17
18
  [["--hermes-dir DIR"], ->(v) { @hermes_dir = File.expand_path(v) }],
18
19
  [["--check"], ->(_) { @check = true }],
19
20
  [["--force"], ->(_) { @force = true }],
@@ -22,7 +23,8 @@ module Maf
22
23
  ].freeze
23
24
 
24
25
  def initialize(argv)
25
- @argv, @agents, @removed, @models = argv, [], [], {}
26
+ @argv, @agents, @removed, @models, @efforts = argv, [], [], {}, {}
27
+ @role_efforts = {}
26
28
  @check = @force = @list_roles = false
27
29
  @bootstrap = true
28
30
  end
@@ -48,8 +50,32 @@ module Maf
48
50
  agent[:model] || @models[agent[:role]] || agent[:saved_model] || Harness[agent[:harness]]&.default_model
49
51
  end
50
52
 
53
+ # An empty --effort ROLE= clears the level: the harness default applies.
54
+ # A saved level belongs to the saved model, so a new model gets the role default.
55
+ def effort_for(agent)
56
+ flag = effort_flag(agent)
57
+ return (flag unless flag.empty?) if flag
58
+
59
+ (agent[:saved_effort] unless model_changed?(agent)) ||
60
+ Models.default_effort(agent[:harness], @role_efforts[agent[:role]], model_for(agent))
61
+ end
62
+
51
63
  private
52
64
 
65
+ # --effort ROLE=LEVEL applies to the agents of this run. Without one of
66
+ # the role, it applies to the saved agents of the role.
67
+ def effort_flag(agent)
68
+ role = agent[:role]
69
+ @efforts[role] if run_agent?(agent) || @agents.none? { |a| a[:role] == role && run_agent?(a) }
70
+ end
71
+
72
+ def run_agent?(agent) = agent.key?(:model)
73
+
74
+ def model_changed?(agent)
75
+ chosen = agent[:model] || @models[agent[:role]]
76
+ chosen && chosen != agent[:saved_model]
77
+ end
78
+
53
79
  def parser
54
80
  OptionParser.new do |o|
55
81
  o.banner = "Usage: maf add|remove|update [HARNESS:ROLE ...] [options]"
@@ -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.
@@ -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}?")
@@ -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