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
@@ -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.
@@ -3,6 +3,7 @@
3
3
  # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
4
  # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
5
  # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require "open3"
6
7
  require_relative "../harness/traits"
7
8
 
8
9
  module Maf
@@ -37,7 +38,7 @@ module Maf
37
38
  name: <%= role %>
38
39
  description: <%= description %>
39
40
  <% if model -%>
40
- model: <%= model %>
41
+ model: <%= Maf::Shared::Harnesses::CursorModels.name(model, effort) %>
41
42
  <% end -%>
42
43
  readonly: <%= !can_edit %>
43
44
  ---
@@ -45,20 +46,75 @@ module Maf
45
46
  <%= prompt %>
46
47
  ERB
47
48
 
48
- def launch(role, model)
49
- ["agent", *model_flag(model), "Read #{role_path(role)} and follow it exactly. Start your work loop now."]
49
+ def launch(role, model, effort = nil)
50
+ warn "maf: Cursor takes the level in the model name. Without a model, maf drops --effort." if effort && !model
51
+ ["agent", *model_flag(CursorModels.name(model, effort)),
52
+ "Read #{role_path(role)} and follow it exactly. Start your work loop now."]
50
53
  end
51
54
 
52
55
  # `agent -p --output-format json` prints one result object with session_id and result.
53
56
  def build_command(config, prompt, session_id)
54
- %w[agent -p --output-format json --force --trust --approve-mcps] + model_flag(config.model) +
57
+ %w[agent -p --output-format json --force --trust
58
+ --approve-mcps] + model_flag(CursorModels.name(config.model, config.effort)) +
55
59
  resume(session_id, "--resume") + [first_prompt(config.role, prompt, session_id)]
56
60
  end
57
61
 
62
+ # Cursor has no effort flag. The level is a part of the model name, so
63
+ # maf lists each model without its level, and the levels of each model.
64
+ def models = CursorModels.bases
65
+
66
+ def known_model?(model, list = models)
67
+ list.nil? || list.include?(model) || CursorModels.listed.include?(model)
68
+ end
69
+
70
+ def efforts(model = nil) = CursorModels.efforts(model)
71
+ def effort_required?(model) = CursorModels.effort_required?(model)
72
+
58
73
  # A failed run prints no JSON, only an error on stderr. So a resume
59
74
  # without a result event and with "not found" is stale. Cursor reports no token usage.
60
75
  def stale_session?(output) = result_event(output).empty? && output.match?(/not found/i)
61
76
  end
77
+
78
+ # CursorModels reads `agent --list-models`. A name is BASE-LEVEL or
79
+ # BASE-LEVEL-fast, for example gpt-5.3-codex-high-fast.
80
+ module CursorModels
81
+ LEVELS = %w[low medium high xhigh max].freeze
82
+ LEVEL = /-(#{LEVELS.join("|")})(?=(?:-fast)?\z)/
83
+
84
+ def self.listed
85
+ return @listed if defined?(@listed)
86
+
87
+ @listed = read
88
+ end
89
+
90
+ def self.read
91
+ out, status = Open3.capture2("agent", "--list-models", err: File::NULL)
92
+ status.success? ? out.scan(/^(\S+) - /).flatten : nil
93
+ rescue SystemCallError
94
+ nil
95
+ end
96
+
97
+ def self.bases = listed&.map { |model| model.sub(LEVEL, "") }&.uniq
98
+
99
+ # nil: maf cannot read the list. A name with a level takes no other level.
100
+ def self.efforts(model)
101
+ return nil unless listed
102
+ return [] if model.nil? || (model.match?(LEVEL) && listed.include?(model))
103
+
104
+ LEVELS.select { |level| listed.include?(name(model, level)) }
105
+ end
106
+
107
+ # A base that Cursor does not list by itself runs only with a level.
108
+ def self.effort_required?(model) = !listed.nil? && !listed.include?(model) && !efforts(model).to_a.empty?
109
+
110
+ # A name with a level gets the new level instead.
111
+ def self.name(model, effort)
112
+ return model unless model && effort
113
+
114
+ model = model.sub(LEVEL, "")
115
+ model.end_with?("-fast") ? model.sub(/-fast\z/, "-#{effort}-fast") : "#{model}-#{effort}"
116
+ end
117
+ end
62
118
  end
63
119
  end
64
120
  end
@@ -13,16 +13,14 @@ module Maf
13
13
  # outside the project. `--skills <project>-<role>` loads one. Hermes has
14
14
  # no agent flag and cannot be woken, so an idle agent blocks in
15
15
  # `coord next --wait`. A role with can_edit false starts without the
16
- # file-writing toolsets. Hermes keeps hooks and MCP servers in global
16
+ # file-writing toolsets. No role gets the kanban or delegation
17
+ # toolset. Hermes keeps hooks and MCP servers in global
17
18
  # files, so maf prints the commands that add them.
18
19
  class Hermes < Harness::Base
19
20
  include Harness::ResultEvent
20
21
 
21
22
  DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
22
23
  MAX_TURNS = 50
23
- # Stop Hermes before the dispatcher kills it, so it can still write its
24
- # result event. NOTE: assets/dispatcher Spawn::GRACE.
25
- KILL_GRACE = 5
26
24
 
27
25
  def sandbox_dirs = [File.join(Dir.home, ".hermes")]
28
26
  PROTECTED = %w[config.yaml .env shell_init.sh SOUL.md hooks agent-hooks shell-hooks-allowlist.json bin scripts
@@ -56,8 +54,11 @@ module Maf
56
54
  ERB
57
55
 
58
56
  def model_hint(_model) = "hermes model"
57
+ EFFORTS = %w[none minimal low medium high xhigh max ultra].freeze
58
+ def efforts(_model = nil) = EFFORTS
59
+ def effort_flag(effort) = super(effort, "--reasoning")
59
60
  def mcp_command(server, vault) = "hermes mcp add #{server} --command ruby --args #{vault} mcp"
60
- def install_hooks(_project) = Flow::HermesHookSetup.new.install
61
+ def install_hooks(_project) = Flow::HermesHookSetup.new.install | Flow::HermesEnvSetup.new.install
61
62
  def global_files = [File.join(Dir.home, ".hermes", "agent-hooks", "next-task.sh")]
62
63
 
63
64
  # The skill name uses the main checkout's name, from the project root
@@ -74,7 +75,11 @@ module Maf
74
75
  Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
75
76
  end
76
77
 
77
- def tools(role) = read_only?(role) ? ["-t", Roles::READ_ONLY_TOOLSETS] : []
78
+ # With -t, Hermes loads an MCP server only when the list names its toolset.
79
+ def tools(role)
80
+ toolsets = read_only?(role) ? Roles::READ_ONLY_TOOLSETS : Roles::EDIT_TOOLSETS
81
+ ["-t", "#{toolsets},mcp-graphify-#{File.basename(Project.root)}"]
82
+ end
78
83
 
79
84
  PROMPT = "Follow the coordination rules of your role skill as role %<role>s. " \
80
85
  "Run coord inbox, then coord next --wait. Claim a task, do the work, finish it. Repeat."
@@ -82,10 +87,10 @@ module Maf
82
87
  "You are a lead role. Never claim a task. Run coord inbox --wait and handle each message."
83
88
 
84
89
  # `-q` on a TTY seeds an interactive session with the prompt.
85
- def launch(role, model)
90
+ def launch(role, model, effort = nil)
86
91
  prompt = format(Roles::LEADS.include?(role) ? LEAD_PROMPT : PROMPT, role: role)
87
- ["hermes", "chat", *skill(role)&.then { |name| ["--skills", name] }, *model_flag(model), *tools(role),
88
- "-q", prompt]
92
+ ["hermes", "chat", *skill(role)&.then { |name| ["--skills", name] }, *model_flag(model), *effort_flag(effort),
93
+ *tools(role), "-q", prompt]
89
94
  end
90
95
 
91
96
  # --no-skill drops the skill. Without --skill, the generated skill of the role loads.
@@ -99,11 +104,16 @@ module Maf
99
104
  # `hermes chat --oneshot` with JSONL output and the skill preloaded.
100
105
  def build_command(config, prompt, session_id)
101
106
  cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q]
102
- cmd += [*(["--skills", config.skill] if config.skill), *model_flag(config.model), *tools(config.role)]
103
- cmd += ["--max-turns", config.max_turns.to_s, "--run-budget", [config.timeout - KILL_GRACE, 1].max.to_s]
107
+ cmd += run_flags(config)
108
+ cmd += ["--max-turns", config.max_turns.to_s]
104
109
  cmd + resume(session_id, "--resume") + ["-q", prompt]
105
110
  end
106
111
 
112
+ def run_flags(config)
113
+ [*(["--skills", config.skill] if config.skill), *model_flag(config.model), *effort_flag(config.effort),
114
+ *tools(config.role)]
115
+ end
116
+
107
117
  def stale_session?(output) = output.include?("Session not found")
108
118
  def result_text(output) = result_event(output)["text"]
109
119
  end
@@ -37,6 +37,9 @@ module Maf
37
37
  <% if model -%>
38
38
  model: <%= model %>
39
39
  <% end -%>
40
+ <% if effort -%>
41
+ variant: <%= effort %>
42
+ <% end -%>
40
43
  temperature: 0.1
41
44
  permission:
42
45
  edit: <%= can_edit ? "allow" : "deny" %>
@@ -92,15 +95,19 @@ module Maf
92
95
  def run_env(config) = config_env(run_file(config, "mcp", "opencode.json"))
93
96
  def config_env(file) = File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
94
97
 
95
- def launch(role, model)
98
+ # The TUI has no variant flag. It reads the variant from the agent file.
99
+ def launch(role, model, effort = nil)
100
+ OpencodeVariant.warn_unused(File.join(role_dir, "#{role}.md"), role, effort)
96
101
  ["opencode", ".", "--agent", role, "--prompt", "Start your work loop now.", *model_flag(model)]
97
102
  end
98
103
 
104
+ def effort_flag(effort) = super(effort, "--variant")
105
+
99
106
  # `opencode run --format json`. The result event shape is not verified,
100
107
  # so the log shows no result text.
101
108
  def build_command(config, prompt, session_id)
102
109
  %w[opencode run --format json --agent] + [config.role] + model_flag(config.model, "-m") +
103
- resume(session_id, "--session") + [prompt]
110
+ effort_flag(config.effort) + resume(session_id, "--session") + [prompt]
104
111
  end
105
112
 
106
113
  # DeepSeek costs twice as much at peak hours.
@@ -164,6 +171,22 @@ module Maf
164
171
  "and json_extract(data,'$.type') = 'tool'"
165
172
  end
166
173
  end
174
+
175
+ # OpencodeVariant tells the user how to change the level of an interactive session.
176
+ module OpencodeVariant
177
+ PATTERN = /\A---\n(?:(?!^---$).)*?^variant:\s*(\S+)/m
178
+ HINT = "maf: the opencode TUI cannot take --effort %<effort>s. The session starts with %<current>s. " \
179
+ "In the session, press ctrl+t to pick the variant %<effort>s. If the agent is in its work " \
180
+ "loop, stop it (Esc) and restart the loop: \"Start your work loop now.\" " \
181
+ "To keep the level for each session: maf add opencode:%<role>s --effort %<role>s=%<effort>s"
182
+
183
+ def self.warn_unused(path, role, effort)
184
+ current = File.exist?(path) ? File.read(path)[PATTERN, 1] : nil
185
+ return if effort.nil? || effort == current
186
+
187
+ warn format(HINT, effort: effort, role: role, current: current ? "variant #{current}" : "the default variant")
188
+ end
189
+ end
167
190
  end
168
191
  end
169
192
  end
@@ -14,6 +14,10 @@ module Maf
14
14
  # Hermes toolsets for a role with can_edit false: no file, code_execution,
15
15
  # or delegation toolset. The shell stays, because the role needs coord and git.
16
16
  READ_ONLY_TOOLSETS = "terminal,web,skills,todo,memory,session_search,clarify"
17
+
18
+ # Hermes toolsets for a role that edits. coord is the only task board and
19
+ # maf starts the workers, so kanban, delegation, and cronjob stay off.
20
+ EDIT_TOOLSETS = "#{READ_ONLY_TOOLSETS},file,code_execution,browser,vision".freeze
17
21
  end
18
22
  end
19
23
  end
data/lib/maf/team.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # team.rb - add, replace, and retire workers in one command.
4
4
  #
5
- # maf prepare HARNESS ROLE[_WORKER] [--model M] [--dispatch | --interactive] [--replace WORKER]
5
+ # maf prepare HARNESS ROLE[_WORKER] [--model M] [--effort L] [--dispatch | --interactive] [--replace WORKER]
6
6
  # maf retire WORKER
7
7
  #
8
8
  # The project manager runs these commands. `maf prepare` adds the role file,
@@ -72,7 +72,7 @@ module Maf
72
72
  end
73
73
 
74
74
  def prepare
75
- Maf.flow("--agent", spec) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
75
+ Maf.flow("--agent", spec, *effort_flag) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
76
76
  worktree = SetupAgent::Worktree.ensure(@args.role, @args.worker_id, @args.harness)
77
77
  SetupAgent::RoleFile.copy(@root, worktree.dir, @args.harness, @args.role)
78
78
  archive_on_harness_change
@@ -87,6 +87,7 @@ module Maf
87
87
  end
88
88
 
89
89
  def spec = [@args.harness, @args.role, @args.model].compact.join(":")
90
+ def effort_flag = @args.effort ? ["--effort", "#{@args.role}=#{@args.effort}"] : []
90
91
 
91
92
  def workers_after
92
93
  all = Workers.at(@root).all
@@ -98,12 +99,12 @@ module Maf
98
99
  def start_dispatcher
99
100
  Dir.chdir(@root)
100
101
  args = [@args.harness, "#{@args.role}_#{@args.worker_id}", "--dispatch", "--detach"]
101
- SetupAgent.run(@args.model ? args + ["--model", @args.model] : args)
102
+ SetupAgent.run(args + { "--model" => @args.model, "--effort" => @args.effort }.compact.flatten)
102
103
  end
103
104
 
104
105
  def entry(dir)
105
106
  { "role" => @args.role, "worker_id" => @args.worker_id, "harness" => @args.harness,
106
- "model" => @args.model, "dispatch" => @args.dispatch, "dir" => dir }.compact
107
+ "model" => @args.model, "effort" => @args.effort, "dispatch" => @args.dispatch, "dir" => dir }.compact
107
108
  end
108
109
 
109
110
  def print_next_steps
@@ -32,10 +32,12 @@ module Maf
32
32
 
33
33
  def line(id, entry)
34
34
  mode = entry["dispatch"] ? "dispatch" : "interactive"
35
- [id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry["model"] || "-", mode, state(id, entry)]
35
+ [id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], model(entry), mode, state(id, entry)]
36
36
  .join(" ")
37
37
  end
38
38
 
39
+ def model(entry) = [entry["model"] || "-", entry["effort"]].compact.join("/")
40
+
39
41
  def state(id, entry)
40
42
  pid = entry["pid"]
41
43
  return "-" unless pid
data/lib/maf/untrack.rb CHANGED
@@ -18,6 +18,11 @@ module Maf
18
18
  @yes = argv.include?("--yes")
19
19
  end
20
20
 
21
+ TRACKED = "maf: git tracks files of the flow. Each maf update changes them in every worktree, " \
22
+ "and coord start-task refuses a dirty worktree. Run maf untrack --check, then maf untrack, then commit."
23
+
24
+ def self.warn_tracked(root) = (warn TRACKED unless new(root, []).tracked.empty?)
25
+
21
26
  def run
22
27
  steps = plan
23
28
  return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
@@ -58,6 +63,8 @@ module Maf
58
63
  $stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
59
64
  end
60
65
 
66
+ public
67
+
61
68
  # The tracked files of .maf/, and the tracked paths of each harness (Harness#untrack_paths).
62
69
  def tracked
63
70
  harness_paths = Maf::Shared::Harness.all.flat_map { |harness| harness.untrack_paths(@root, method(:git_files)) }
data/lib/maf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maf
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -26,7 +26,7 @@ module Maf
26
26
  @root = root
27
27
  @force = force
28
28
  @limits = limits
29
- @worker = Workers.id(spec)
29
+ @worker = resolve(Workers.at(root).all, Workers.id(spec))
30
30
  @entry = Workers.at(root).find(@worker) || abort("maf: no worker #{@worker}")
31
31
  end
32
32
 
@@ -39,6 +39,17 @@ module Maf
39
39
 
40
40
  private
41
41
 
42
+ def resolve(workers, worker)
43
+ return worker if workers.key?(worker)
44
+
45
+ only_worker(worker, workers.select { |_, entry| entry["role"] == worker }.keys) || worker
46
+ end
47
+
48
+ def only_worker(role, names)
49
+ abort "maf: role #{role} has more than one worker: #{names.join(", ")}. Name one." if names.size > 1
50
+ names.first
51
+ end
52
+
42
53
  def status = puts("#{@worker}: #{live? ? "running (pid #{pid})" : "stopped"}")
43
54
 
44
55
  def stop