maf 0.1.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +29 -11
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/analyst +29 -51
  6. data/assets/coord +168 -41
  7. data/assets/dashboard +1 -5
  8. data/assets/dispatcher +127 -298
  9. data/assets/harness-hooks/context-watch.rb +5 -4
  10. data/assets/lima/maf.yaml.erb +61 -0
  11. data/install.md +6 -1
  12. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  13. data/lib/maf/bootstrap/script_planner.rb +1 -1
  14. data/lib/maf/bootstrap.rb +4 -0
  15. data/lib/maf/cli.rb +22 -9
  16. data/lib/maf/flow/agent_links.rb +2 -2
  17. data/lib/maf/flow/bootstrapper.rb +5 -5
  18. data/lib/maf/flow/codex_hooks.rb +12 -1
  19. data/lib/maf/flow/effort_report.rb +20 -0
  20. data/lib/maf/flow/generator.rb +3 -2
  21. data/lib/maf/flow/harness_linker.rb +2 -2
  22. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  23. data/lib/maf/flow/hook_files.rb +6 -0
  24. data/lib/maf/flow/hook_installer.rb +6 -20
  25. data/lib/maf/flow/manifest.rb +3 -3
  26. data/lib/maf/flow/mcp_config.rb +8 -22
  27. data/lib/maf/flow/mcp_installer.rb +3 -3
  28. data/lib/maf/flow/models.rb +14 -38
  29. data/lib/maf/flow/options.rb +33 -5
  30. data/lib/maf/flow/prompt_builder.rb +7 -14
  31. data/lib/maf/flow/prompt_text.rb +13 -14
  32. data/lib/maf/flow/report.rb +8 -9
  33. data/lib/maf/flow/role_catalog.rb +1 -1
  34. data/lib/maf/flow/role_files.rb +7 -22
  35. data/lib/maf/flow/roster.rb +6 -3
  36. data/lib/maf/flow/validator.rb +2 -2
  37. data/lib/maf/flow.rb +7 -21
  38. data/lib/maf/menu.rb +49 -15
  39. data/lib/maf/migrate/role_files.rb +3 -1
  40. data/lib/maf/retire.rb +42 -6
  41. data/lib/maf/setup_agent/args.rb +16 -10
  42. data/lib/maf/setup_agent/dispatch.rb +3 -3
  43. data/lib/maf/setup_agent/launcher.rb +16 -65
  44. data/lib/maf/setup_agent/manifest.rb +9 -3
  45. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  46. data/lib/maf/setup_agent/role_file.rb +1 -1
  47. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  48. data/lib/maf/setup_agent.rb +11 -13
  49. data/lib/maf/shared/harness/base.rb +57 -0
  50. data/lib/maf/shared/harness/defaults.rb +147 -0
  51. data/lib/maf/shared/harness/output.rb +55 -0
  52. data/lib/maf/shared/harness/traits.rb +35 -0
  53. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  54. data/lib/maf/shared/harness.rb +12 -0
  55. data/lib/maf/shared/harnesses/claude.rb +137 -0
  56. data/lib/maf/shared/harnesses/codex.rb +202 -0
  57. data/lib/maf/shared/harnesses/cursor.rb +120 -0
  58. data/lib/maf/shared/harnesses/hermes.rb +122 -0
  59. data/lib/maf/shared/harnesses/opencode.rb +192 -0
  60. data/lib/maf/shared/processes.rb +20 -0
  61. data/lib/maf/shared/roles.rb +4 -0
  62. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  63. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  64. data/lib/maf/shared/sandbox/links.rb +28 -0
  65. data/lib/maf/shared/sandbox/paths.rb +124 -0
  66. data/lib/maf/shared/sandbox/references.rb +64 -0
  67. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  68. data/lib/maf/shared/sandbox.rb +84 -0
  69. data/lib/maf/team.rb +5 -4
  70. data/lib/maf/team_command.rb +5 -3
  71. data/lib/maf/uninstall/manifest.rb +10 -8
  72. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  73. data/lib/maf/uninstall/notes.rb +13 -2
  74. data/lib/maf/uninstall/role_files.rb +11 -10
  75. data/lib/maf/uninstall/runner.rb +4 -3
  76. data/lib/maf/uninstall/scripts.rb +1 -1
  77. data/lib/maf/uninstall/vm_files.rb +16 -0
  78. data/lib/maf/uninstall.rb +20 -5
  79. data/lib/maf/untrack.rb +12 -25
  80. data/lib/maf/version.rb +1 -1
  81. data/lib/maf/vm/login.rb +52 -0
  82. data/lib/maf/vm/options.rb +41 -0
  83. data/lib/maf/vm/template.rb +44 -0
  84. data/lib/maf/vm.rb +94 -0
  85. data/lib/maf/worker_control.rb +16 -8
  86. data/lib/maf/worker_list.rb +83 -0
  87. data/lib/maf/workers.rb +27 -0
  88. data/templates/roles.yml +14 -0
  89. metadata +33 -11
  90. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  91. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  92. data/templates/claude.md.erb +0 -16
  93. data/templates/codex.md.erb +0 -7
  94. data/templates/hermes.md.erb +0 -12
  95. data/templates/opencode.md.erb +0 -24
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
-
5
3
  module Maf
6
4
  module Flow
7
5
  # Models finds the installed harnesses and the models that each one offers.
@@ -9,52 +7,30 @@ module Maf
9
7
  # harness does not know: a typo makes every dispatched run fail.
10
8
  # A list is nil when maf cannot read it. Then every name counts as known.
11
9
  module Models
12
- # Claude Code has no command that lists models. The aliases follow the
13
- # newest model of each family. A full ID is claude-<family>-<version>.
14
- CLAUDE = %w[claude-opus-5-5 claude-sonnet-5-5 claude-haiku-5-5 claude-fable-5-1 opus sonnet haiku fable].freeze
15
- CLAUDE_NAME = /\A(?:(?:opus|sonnet|haiku|fable)(?:\[1m\])?|opusplan|default|best|
16
- (?:[\w.-]+\.)?(?:anthropic\.)?claude-(?:opus|sonnet|haiku|fable)-\d[\w.:@\[\]-]*)\z/x
17
- CODEX_CACHE = File.join(Dir.home, ".codex", "models_cache.json")
18
-
19
10
  # Without any harness on PATH, maf shows all of them: the user may install one later.
20
11
  def self.installed
21
- found = HARNESSES.select { |harness| on_path?(harness) }
22
- found.empty? ? HARNESSES : found
23
- end
24
-
25
- def self.on_path?(command)
26
- ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
12
+ found = Harness.all.select(&:installed?).map(&:name)
13
+ found.empty? ? Harness.names : found
27
14
  end
28
15
 
16
+ # A harness can read its list from a CLI, so each list is read once.
29
17
  def self.for(harness)
30
18
  @lists ||= {}
31
- return @lists[harness] if @lists.key?(harness)
32
-
33
- @lists[harness] = { "claude" => -> { CLAUDE }, "codex" => -> { codex }, "opencode" => -> { opencode } }
34
- .fetch(harness, -> {}).call
19
+ @lists.key?(harness) ? @lists[harness] : @lists[harness] = Harness[harness]&.models
35
20
  end
36
21
 
37
- def self.known?(harness, model)
38
- return model.match?(CLAUDE_NAME) if harness == "claude"
39
-
40
- self.for(harness).nil? || self.for(harness).include?(model)
41
- end
42
-
43
- # Codex keeps the model list of the account in a cache file. Hidden models are internal.
44
- def self.codex
45
- models = JSON.parse(File.read(CODEX_CACHE)).fetch("models")
46
- models.select { |m| m["visibility"] == "list" }.map { |m| m["slug"] }
47
- rescue SystemCallError, JSON::ParserError, KeyError, NoMethodError
48
- nil
49
- end
22
+ def self.known?(harness, model) = Harness[harness]&.known_model?(model, self.for(harness)) != false
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))
50
26
 
51
- def self.opencode
52
- return nil unless on_path?("opencode")
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)
53
32
 
54
- out, status = Open3.capture2("opencode", "models", err: File::NULL)
55
- status.success? && !out.strip.empty? ? out.split : nil
56
- rescue SystemCallError
57
- nil
33
+ levels.first if Harness[harness]&.effort_required?(model)
58
34
  end
59
35
  end
60
36
  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,8 +23,8 @@ module Maf
22
23
  ].freeze
23
24
 
24
25
  def initialize(argv)
25
- @argv, @agents, @removed, @models = argv, [], [], {}
26
- @hermes_dir = DEFAULT_HERMES_DIR
26
+ @argv, @agents, @removed, @models, @efforts = argv, [], [], {}, {}
27
+ @role_efforts = {}
27
28
  @check = @force = @list_roles = false
28
29
  @bootstrap = true
29
30
  end
@@ -32,6 +33,9 @@ module Maf
32
33
  def force? = @force
33
34
  def bootstrap? = @bootstrap
34
35
 
36
+ # The settings that a harness reads for its role file folder.
37
+ def settings = { "hermes_dir" => @hermes_dir }.compact
38
+
35
39
  def parse
36
40
  parser.parse!(@argv)
37
41
  self
@@ -43,11 +47,35 @@ module Maf
43
47
  end
44
48
 
45
49
  def model_for(agent)
46
- agent[:model] || @models[agent[:role]] || agent[:saved_model] || DEFAULT_MODELS[agent[:harness]]
50
+ agent[:model] || @models[agent[:role]] || agent[:saved_model] || Harness[agent[:harness]]&.default_model
51
+ end
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))
47
61
  end
48
62
 
49
63
  private
50
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
+
51
79
  def parser
52
80
  OptionParser.new do |o|
53
81
  o.banner = "Usage: maf add|remove|update [HARNESS:ROLE ...] [options]"
@@ -26,26 +26,19 @@ module Maf
26
26
  "#{intro(data)}\n\n#{duties_block(data)}\n\n#{loop_text}"
27
27
  end
28
28
 
29
- # Claude Code wakes an idle session with the board-watch hook, and opencode
30
- # with the board-watch plugin. So these agents stop instead of waiting:
31
- # each return of a wait costs one model call over the whole context.
32
- # Codex waits in its stop hook after `coord await`. Hermes cannot be
33
- # woken, so it blocks in `coord next --wait`.
34
- # The dispatcher adds the report block rule to each dispatched prompt.
35
- def no_task_line(harness)
36
- return NO_TASK_AWAIT if harness == "codex"
37
-
38
- WAKE_HARNESSES.include?(harness) ? NO_TASK_STOP : NO_TASK_WAIT
39
- end
29
+ # A harness that wakes an idle session lets the agent stop instead of
30
+ # waiting: each return of a wait costs one model call over the whole
31
+ # context. The dispatcher adds the report block rule to each dispatched prompt.
32
+ def no_task_line(harness) = NO_TASK.fetch(harness.wait)
40
33
 
41
34
  def architect_prompt(harness, data)
42
35
  loop_text = project_manager? ? ARCHITECT_LOOP_PM : ARCHITECT_LOOP_DIRECT
43
36
  "#{intro(data)} You do not implement code yourself.\n\n#{duties_block(data)}\n\n" \
44
- "#{format(loop_text, roles: dispatch_roles_text)}#{claude_rule(harness)}#{workflow_block}"
37
+ "#{format(loop_text, roles: dispatch_roles_text)}#{harness_rule(harness)}#{workflow_block}"
45
38
  end
46
39
 
47
40
  # The loop text ends with a newline, so the rule lands as the last rule line.
48
- def claude_rule(harness) = harness == "claude" ? "#{CLAUDE_ARCHITECT_RULE}\n" : ""
41
+ def harness_rule(harness) = harness.architect_rule ? "#{harness.architect_rule}\n" : ""
49
42
 
50
43
  # The workflow goes into the orchestrator prompt only. Workers stay workflow-blind.
51
44
  def workflow_block = @workflow ? "\n\n#{@workflow}" : ""
@@ -56,7 +49,7 @@ module Maf
56
49
 
57
50
  def project_manager_prompt(harness, data)
58
51
  "#{intro(data)} You do not plan tasks or implement code yourself.\n\n" \
59
- "#{duties_block(data)}\n\n#{format(PM_LOOP, wait_instruction: PM_WAIT.fetch(harness, PM_WAIT_DEFAULT))}"
52
+ "#{duties_block(data)}\n\n#{format(PM_LOOP, wait_instruction: PM_WAIT.fetch(harness.wait))}"
60
53
  end
61
54
 
62
55
  def intro(data)
@@ -21,8 +21,7 @@ module Maf
21
21
  - Do not use subagents to verify your work.
22
22
  TEXT
23
23
 
24
- # Harnesses with a watcher that wakes an idle session.
25
- WAKE_HARNESSES = %w[claude opencode].freeze
24
+ # The no-task rule of a worker, by the wait of the harness (Harness::RoleFiles#wait).
26
25
  NO_TASK_STOP = "- If no task and no message is available, stop. The board watcher wakes you when work arrives.\n" \
27
26
  " Do not wait in a loop: each return of a wait costs one model call."
28
27
  NO_TASK_WAIT = <<~TEXT.strip
@@ -37,15 +36,16 @@ module Maf
37
36
  The stop hook waits for work and wakes you. Do not wait in a loop.
38
37
  TEXT
39
38
 
39
+ NO_TASK = { watch: NO_TASK_STOP, await: NO_TASK_AWAIT, poll: NO_TASK_WAIT }.freeze
40
+
40
41
  # The project manager waits for reports. Each check in a loop sends the
41
42
  # whole context again, so the wait must not call the model.
42
43
  PM_WAIT = {
43
- "claude" => "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
44
- "opencode" => "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
45
- "codex" => "To wait for reports, run `coord await`. Tell the user that you wait. Then end your turn.\n" \
46
- " The stop hook wakes you when a message arrives."
44
+ watch: "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
45
+ await: "To wait for reports, run `coord await`. Tell the user that you wait. Then end your turn.\n" \
46
+ " The stop hook wakes you when a message arrives.",
47
+ poll: "Wait for reports with `coord inbox project-manager --wait --timeout 540`."
47
48
  }.freeze
48
- PM_WAIT_DEFAULT = "Wait for reports with `coord inbox project-manager --wait --timeout 540`."
49
49
 
50
50
  # Every role except the project manager reads the code graph before work.
51
51
  # A query output stays in the context for each later call, so the budget is small.
@@ -56,10 +56,6 @@ module Maf
56
56
  Put one line in the report of that task or plan: "Graph: fresh", "Graph: stale", or "Graph: missing".
57
57
  TEXT
58
58
 
59
- # Only Claude Code has ScheduleWakeup. Other harnesses never see this rule.
60
- CLAUDE_ARCHITECT_RULE = "- Never call ScheduleWakeup with `stop:false` and no `prompt`. " \
61
- "The call fails. Poll worker status through the task tool instead."
62
-
63
59
  # The coordination contract that every role shares. It lives in the role
64
60
  # prompt, not in the project's AGENTS.md: only a session that maf starts
65
61
  # needs it, and the project keeps no trace of the tool.
@@ -103,9 +99,12 @@ module Maf
103
99
  - Before you continue a claimed task after a pause, run `coord show <id>`. If the status is not pending,
104
100
  or the worker is not you, stop work on that task. `coord reap` releases the claims of stalled workers.
105
101
  - Finish the whole task. Report done only when each acceptance criterion passes.
106
- - If you cannot finish, do the parts you can. Keep the claim, so another worker
107
- does not hit the same blocker. Annotate the blocker and the missing parts.
108
- 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.
109
108
  %{no_task_instruction}
110
109
  - Record durable knowledge in the shared vault or #{DECISIONS}.
111
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)
@@ -74,16 +75,14 @@ module Maf
74
75
  def pm?(result) = result[:agent][:role] == "project-manager"
75
76
 
76
77
  def print_unembeddable(results)
77
- list = results.select { |r| r[:model] && %w[codex hermes].include?(r[:agent][:harness]) }
78
+ list = results.select { |r| r[:model] && model_command(r) }
78
79
  return if list.empty?
79
80
 
80
81
  puts "", "These harnesses cannot embed a model in the role file. Set it in the harness:",
81
82
  list.map { |r| " #{r[:agent][:harness]}:#{r[:agent][:role]} -> #{model_command(r)}" }
82
83
  end
83
84
 
84
- def model_command(result)
85
- result[:agent][:harness] == "codex" ? "codex -m #{result[:model]}" : "hermes model"
86
- end
85
+ def model_command(result) = Harness.fetch(result[:agent][:harness]).model_hint(result[:model])
87
86
  end
88
87
  end
89
88
  end
@@ -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
@@ -19,30 +19,15 @@ module Maf
19
19
  private
20
20
 
21
21
  def generate_agent(agent)
22
- role = agent[:role]
23
- model = @options.model_for(agent)
24
- dest = destination(agent[:harness], role)
25
- content = render(agent[:harness], role, @roles.fetch(role), model)
26
- { agent: agent, dest: dest, status: write(dest, content), model: model }
22
+ harness = Harness.fetch(agent[:harness])
23
+ choice = { model: @options.model_for(agent), effort: @options.effort_for(agent) }
24
+ dest = harness.role_destination(@options.project, agent[:role], @options.settings)
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
- # Hermes skills live in a global ~/.hermes/skills/ directory, not the
30
- # project. Namespace by project so two projects using the same role
31
- # don't overwrite each other's skill.
32
- def destination(harness, role)
33
- return hermes_path(role) if harness == "hermes"
34
-
35
- File.join(@options.project, Flow.role_path(harness, role)) if %w[opencode claude codex].include?(harness)
36
- end
37
-
38
- def hermes_path(role)
39
- File.join(@options.hermes_dir, "#{File.basename(@options.project)}-#{role}", "SKILL.md")
40
- end
41
-
42
- def render(harness, role, data, model)
43
- template = File.read(File.join(TEMPLATES, "#{harness}.md.erb"))
44
- values = { prompt: @prompts.build(harness, role, data), model: model }
45
- ERB.new(template, trim_mode: "-").result_with_hash(**template_values(role, data), **values)
29
+ def render(harness, role, data, choice)
30
+ harness.render_role(**template_values(role, data), prompt: @prompts.build(harness, role, data), **choice)
46
31
  end
47
32
 
48
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]
@@ -28,11 +28,11 @@ module Maf
28
28
  private
29
29
 
30
30
  def check(agent)
31
- abort "flow: unknown harness '#{agent[:harness]}' (use #{HARNESSES.join(", ")})" unless known?(agent)
31
+ abort "flow: unknown harness '#{agent[:harness]}' (use #{Harness.names.join(", ")})" unless known?(agent)
32
32
  abort "flow: unknown role '#{agent[:role]}'" unless @roles.key?(agent[:role])
33
33
  end
34
34
 
35
- def known?(agent) = HARNESSES.include?(agent[:harness])
35
+ def known?(agent) = Harness.known?(agent[:harness])
36
36
  end
37
37
  end
38
38
  end
data/lib/maf/flow.rb CHANGED
@@ -4,10 +4,10 @@
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
- # HARNESS is one of: opencode, claude, codex, hermes.
10
+ # HARNESS is a harness of the registry: one file in lib/maf/shared/harnesses/.
11
11
  # A re-run keeps the agents in .maf/config.json. --agent adds an agent.
12
12
  # --remove drops an agent. Without --agent, a re-run regenerates the current agents.
13
13
  # Idempotent: identical files are skipped, changed files are updated, and
@@ -20,6 +20,7 @@ require "optparse"
20
20
  require "rbconfig"
21
21
  require "time"
22
22
  require_relative "shared/roles"
23
+ require_relative "shared/harness"
23
24
 
24
25
  abort "flow: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
25
26
 
@@ -28,32 +29,15 @@ module Maf
28
29
  ROOT = File.expand_path("../..", __dir__)
29
30
  TEMPLATES = File.join(ROOT, "templates")
30
31
  ASSETS = File.join(ROOT, "assets")
31
- HARNESSES = %w[opencode claude codex hermes].freeze
32
- DEFAULT_HERMES_DIR = File.join(Dir.home, ".hermes", "skills")
33
-
34
- # Model a harness gets when neither --agent nor --model names one.
35
- # Other harnesses have no safe default, so their CLI picks the model.
36
- DEFAULT_MODELS = { "claude" => "claude-opus-5-5" }.freeze
32
+ # The registry of the harnesses. Each harness is one file in lib/maf/shared/harnesses/.
33
+ Harness = Maf::Shared::Harness
37
34
 
38
35
  # Roles that dispatch or coordinate instead of implementing. They are never
39
36
  # advertised as dispatch targets in the architect prompt.
40
37
  LEADS = Maf::Shared::Roles::LEADS
41
38
  DISPATCH_EXCLUDE = LEADS
42
39
 
43
- READ_ONLY_TOOLSETS = Maf::Shared::Roles::READ_ONLY_TOOLSETS
44
-
45
40
  MAF_DIR = ".maf"
46
-
47
- # The folder where a harness looks for role files. It is a symlink into
48
- # .maf/agents/<harness>/, where the flow keeps the role files.
49
- HARNESS_DIRS = { "opencode" => ".opencode/agents", "claude" => ".claude/agents",
50
- "codex" => ".codex/prompts" }.freeze
51
-
52
- # The role file path inside the project. Hermes keeps role files outside
53
- # the project, so it has no path here.
54
- def self.role_path(harness, role)
55
- HARNESS_DIRS.key?(harness) ? File.join(MAF_DIR, "agents", harness, "#{role}.md") : nil
56
- end
57
41
  end
58
42
  end
59
43
 
@@ -68,6 +52,7 @@ require_relative "flow/options"
68
52
  require_relative "flow/prompt_builder"
69
53
  require_relative "flow/hook_files"
70
54
  require_relative "flow/hermes_hook_setup"
55
+ require_relative "flow/hermes_env_setup"
71
56
  require_relative "flow/codex_hooks"
72
57
  require_relative "flow/legacy_codex_hook"
73
58
  require_relative "flow/hook_installer"
@@ -77,6 +62,7 @@ require_relative "flow/mcp_installer"
77
62
  require_relative "flow/role_files"
78
63
  require_relative "flow/manifest"
79
64
  require_relative "flow/models"
65
+ require_relative "flow/effort_report"
80
66
  require_relative "flow/report"
81
67
  require_relative "flow/validator"
82
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,32 +56,66 @@ 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
- def default_label(harness) = Flow::DEFAULT_MODELS.fetch(harness, "the harness default")
73
+ def default_label(harness) = Flow::Models.default(harness) || "the harness default"
77
74
 
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}?")
@@ -11,7 +11,9 @@ module Maf
11
11
  end
12
12
 
13
13
  def steps
14
- Flow::HARNESS_DIRS.flat_map { |harness, dir| files(dir).map { |file| step(harness, dir, file) } }
14
+ Maf::Shared::Harness.all.select(&:role_dir).flat_map do |harness|
15
+ files(harness.role_dir).map { |file| step(harness.name, harness.role_dir, file) }
16
+ end
15
17
  end
16
18
 
17
19
  private