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
@@ -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
@@ -11,6 +11,7 @@ module Maf
11
11
  # Seatbelt runs a command in a macOS sandbox-exec profile.
12
12
  module Seatbelt
13
13
  COMMAND = "/usr/bin/sandbox-exec"
14
+ MARKER = "MAF_SEATBELT"
14
15
  DEVICES = '(literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") ' \
15
16
  '(regex #"^/dev/tty") (regex #"^/dev/fd/") (regex #"^/dev/ptmx")'
16
17
  DNS = '(path-literal "/private/var/run/mDNSResponder")'
@@ -20,9 +21,12 @@ module Maf
20
21
 
21
22
  def self.wrap(cmd, paths)
22
23
  tmp = private_tmp(paths.root)
23
- [COMMAND, "-p", profile(paths, tmp), "env", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache", *cmd]
24
+ [COMMAND, "-p", profile(paths, tmp), "env", "#{MARKER}=1", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache",
25
+ *cmd]
24
26
  end
25
27
 
28
+ def self.inside? = ENV[MARKER] == "1"
29
+
26
30
  def self.available? = File.executable?(COMMAND)
27
31
  def self.require! = available? || abort("maf: sandbox mode seatbelt needs #{COMMAND} (macOS only).")
28
32
 
@@ -18,6 +18,8 @@ module Maf
18
18
  BACKENDS = { "seatbelt" => Seatbelt, "bwrap" => Bwrap }.freeze
19
19
  MODES = ["off", "auto", *BACKENDS.keys].freeze
20
20
  POLICY = File.join(".maf", "sandbox.json")
21
+ NESTED = "maf: this process runs in the macOS sandbox, and macOS cannot start a second sandbox in it. " \
22
+ "Start the worker from your terminal: cd <worktree> && maf start"
21
23
 
22
24
  def self.policy
23
25
  path = File.join(Project.root, POLICY)
@@ -40,9 +42,13 @@ module Maf
40
42
  def self.wrap(cmd, harness, coord_dir: ENV.fetch("COORD_DIR", nil))
41
43
  policy = self.policy
42
44
  mode = mode(policy)
45
+ refuse_nested!(mode)
43
46
  mode == "off" ? cmd : BACKENDS[mode].wrap(harness.sandboxed(cmd), paths(harness, policy, coord_dir))
44
47
  end
45
48
 
49
+ def self.nested?(mode = self.mode) = mode == "seatbelt" && Seatbelt.inside?
50
+ def self.refuse_nested!(mode = self.mode) = nested?(mode) && abort(NESTED)
51
+
46
52
  def self.keeping_links(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
47
53
  links = protected_links(harness, coord_dir)
48
54
  yield
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,
@@ -24,6 +24,7 @@ module Maf
24
24
  end
25
25
 
26
26
  def self.notify(root, text) = coord(root, "msg", "--from", "maf", "architect", text)
27
+ def self.agent? = !ENV["COORD_ROLE"].to_s.empty?
27
28
  end
28
29
 
29
30
  # Prepare makes a worker ready to start. With --replace, it retires the
@@ -35,6 +36,8 @@ module Maf
35
36
  # The architect never talks to the user. A dispatched architect gets fresh
36
37
  # sessions with a handoff note, so its context stays small.
37
38
  DISPATCH_DEFAULT = %w[architect].freeze
39
+ NO_BUDGET = "maf: an agent cannot change the team without a budget. Ask the user to set one " \
40
+ "(maf team set --max N), or to run this command in a terminal."
38
41
 
39
42
  def self.mode_args(argv)
40
43
  return argv - ["--interactive"] if argv.include?("--interactive")
@@ -52,7 +55,7 @@ module Maf
52
55
  end
53
56
 
54
57
  def run
55
- @budget.check!(@args.harness, @args.model, @budget.count(workers_after))
58
+ check_budget
56
59
  retire = (Retire.new(@root, @replace).tap(&:check!) if @replace)
57
60
  Dir.chdir(@root) { prepare }
58
61
  retire.run if retire && Workers.id(@replace) != @args.worker
@@ -61,18 +64,23 @@ module Maf
61
64
 
62
65
  def announce
63
66
  Team.notify(@root, "Team change: worker #{@args.worker} (#{@args.harness}) joined as #{@args.role}.")
64
- @args.dispatch ? start_dispatcher : print_next_steps
67
+ @args.dispatch && !Shared::Sandbox.nested? ? start_dispatcher : print_next_steps
65
68
  end
66
69
 
67
70
  private
68
71
 
72
+ def check_budget
73
+ abort NO_BUDGET if Team.agent? && !@budget.limited?
74
+ @budget.check!(@args.harness, @args.model, @budget.count(workers_after))
75
+ end
76
+
69
77
  def take_replace(argv)
70
78
  index = argv.index("--replace") or return nil
71
79
  argv.slice!(index, 2)[1] || abort("maf: --replace needs a worker, for example backend-developer_2")
72
80
  end
73
81
 
74
82
  def prepare
75
- Maf.flow("--agent", spec) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
83
+ Maf.flow("--agent", spec, *effort_flag) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
76
84
  worktree = SetupAgent::Worktree.ensure(@args.role, @args.worker_id, @args.harness)
77
85
  SetupAgent::RoleFile.copy(@root, worktree.dir, @args.harness, @args.role)
78
86
  archive_on_harness_change
@@ -87,6 +95,7 @@ module Maf
87
95
  end
88
96
 
89
97
  def spec = [@args.harness, @args.role, @args.model].compact.join(":")
98
+ def effort_flag = @args.effort ? ["--effort", "#{@args.role}=#{@args.effort}"] : []
90
99
 
91
100
  def workers_after
92
101
  all = Workers.at(@root).all
@@ -98,12 +107,12 @@ module Maf
98
107
  def start_dispatcher
99
108
  Dir.chdir(@root)
100
109
  args = [@args.harness, "#{@args.role}_#{@args.worker_id}", "--dispatch", "--detach"]
101
- SetupAgent.run(@args.model ? args + ["--model", @args.model] : args)
110
+ SetupAgent.run(args + { "--model" => @args.model, "--effort" => @args.effort }.compact.flatten)
102
111
  end
103
112
 
104
113
  def entry(dir)
105
114
  { "role" => @args.role, "worker_id" => @args.worker_id, "harness" => @args.harness,
106
- "model" => @args.model, "dispatch" => @args.dispatch, "dir" => dir }.compact
115
+ "model" => @args.model, "effort" => @args.effort, "dispatch" => @args.dispatch, "dir" => dir }.compact
107
116
  end
108
117
 
109
118
  def print_next_steps
@@ -18,7 +18,14 @@ module Maf
18
18
  @root = root
19
19
  end
20
20
 
21
- def run = @argv.first == "set" ? set(@argv.drop(1)) : show
21
+ AGENT_SET = "maf: only the user sets the team budget. Give the user this command to run in a terminal."
22
+
23
+ def run
24
+ return show unless @argv.first == "set"
25
+
26
+ abort AGENT_SET if Team.agent?
27
+ set(@argv.drop(1))
28
+ end
22
29
 
23
30
  private
24
31
 
@@ -32,10 +39,12 @@ module Maf
32
39
 
33
40
  def line(id, entry)
34
41
  mode = entry["dispatch"] ? "dispatch" : "interactive"
35
- [id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry["model"] || "-", mode, state(id, entry)]
42
+ [id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], model(entry), mode, state(id, entry)]
36
43
  .join(" ")
37
44
  end
38
45
 
46
+ def model(entry) = [entry["model"] || "-", entry["effort"]].compact.join("/")
47
+
39
48
  def state(id, entry)
40
49
  pid = entry["pid"]
41
50
  return "-" unless pid
data/lib/maf/untrack.rb CHANGED
@@ -18,6 +18,15 @@ 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
+
26
+ DONE = ["", "The flow left git. Review the change with `git status`, then commit it.",
27
+ "A branch that still tracks the flow files can delete them in a checkout or a merge.",
28
+ "maf keeps a backup of the flow config in .git/maf/backup. Run `maf restore` to put it back."].freeze
29
+
21
30
  def run
22
31
  steps = plan
23
32
  return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
@@ -50,7 +59,7 @@ module Maf
50
59
  abort "maf: cancelled" unless @yes || confirmed?
51
60
  steps.each(&:run)
52
61
  Dir.chdir(@root) { Maf.flow }
53
- puts "", "The flow left git. Review the change with `git status`, then commit it."
62
+ puts DONE
54
63
  end
55
64
 
56
65
  def confirmed?
@@ -58,6 +67,8 @@ module Maf
58
67
  $stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
59
68
  end
60
69
 
70
+ public
71
+
61
72
  # The tracked files of .maf/, and the tracked paths of each harness (Harness#untrack_paths).
62
73
  def tracked
63
74
  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.4.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
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # worker_list.rb - list the workers that run now: sessions and dispatchers.
4
+ require "json"
5
+ require "time"
6
+ require_relative "workers"
7
+ require_relative "shared/processes"
8
+
9
+ module Maf
10
+ # WorkerList shows each running worker process of the project. A worker
11
+ # can run twice, for example as a session and as a dispatcher.
12
+ class WorkerList
13
+ Row = Struct.new(:worker, :role, :harness, :mode, :pid, :started)
14
+ HEADER = %w[WORKER ROLE HARNESS MODE PID STARTED].freeze
15
+ LSTART = "%a %b %d %H:%M:%S %Y %z"
16
+
17
+ def initialize(root)
18
+ @root = root
19
+ @workers = Workers.at(root)
20
+ end
21
+
22
+ def run
23
+ rows = running
24
+ puts rows.empty? ? "No worker runs." : table([HEADER] + rows.map { |row| row.to_a.map(&:to_s) }), warnings(rows)
25
+ end
26
+
27
+ private
28
+
29
+ def running = (sessions + dispatchers + registered).uniq(&:pid).sort_by { |row| [row.worker, row.mode] }
30
+
31
+ def coord_dir = File.join(@root, ".maf", "coordination")
32
+ def records(dir, pattern) = Dir.glob(File.join(coord_dir, dir, pattern)).map { |path| [path, read(path)] }
33
+
34
+ def sessions
35
+ records("sessions", "*.maf.json").filter_map do |path, record|
36
+ row(record, "session") if session_live?(record["pid"], File.mtime(path))
37
+ end
38
+ end
39
+
40
+ def session_live?(pid, registered)
41
+ started = Shared::Processes.started_at(pid)
42
+ Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
43
+ rescue ArgumentError
44
+ false
45
+ end
46
+
47
+ def dispatchers
48
+ records("presence", "*.json").filter_map do |_, entry|
49
+ row(entry, "dispatch") if entry["mode"] == "dispatch" && Shared::Processes.live?(entry)
50
+ end
51
+ end
52
+
53
+ def registered
54
+ @workers.all.filter_map do |worker, entry|
55
+ row(entry.merge("worker" => worker), "dispatch") if @workers.dispatcher?(worker)
56
+ end
57
+ end
58
+
59
+ def row(data, mode)
60
+ entry = @workers.find(data["worker"]).to_h
61
+ Row.new(data["worker"], data["role"] || entry["role"], data["harness"] || entry["harness"], mode,
62
+ data["pid"].to_i, Shared::Processes.started_at(data["pid"]))
63
+ end
64
+
65
+ def table(lines)
66
+ widths = lines.transpose.map { |column| column.map(&:size).max }
67
+ lines.map { |line| line.zip(widths).map { |text, width| text.ljust(width) }.join(" ").rstrip }
68
+ end
69
+
70
+ def warnings(rows)
71
+ twice = rows.group_by(&:worker).select { |_, list| list.size > 1 }
72
+ stopped = @workers.all.keys - rows.map(&:worker)
73
+ twice.map { |worker, list| "warning: #{worker} runs #{list.size} times (pids #{list.map(&:pid).join(", ")})" } +
74
+ (stopped.empty? ? [] : ["stopped: #{stopped.join(", ")}"])
75
+ end
76
+
77
+ def read(path)
78
+ JSON.parse(File.read(path))
79
+ rescue JSON::ParserError, SystemCallError
80
+ {}
81
+ end
82
+ end
83
+ end
data/templates/roles.yml CHANGED
@@ -4,6 +4,9 @@
4
4
  # the model with --model ROLE=MODEL. If the user does not choose a model,
5
5
  # claude roles get lib/maf/flow.rb's DEFAULT_MODELS entry (claude-opus-5-5).
6
6
  #
7
+ # `effort` is the default effort level. A role gets it when the harness lists
8
+ # the level. --effort ROLE=LEVEL overrides it. A lower level uses fewer tokens.
9
+ #
7
10
  # `description` is also the routing line in the architect's "Available roles"
8
11
  # list. Keep it short and specific to the role.
9
12
  #
@@ -42,6 +45,7 @@ roles:
42
45
  - If a message starts with "ESCALATION", a role cannot fix a problem. Tell the user in plain words:
43
46
  what failed, which task, and the impact. Give two or three options with a recommended one.
44
47
  Wait for the answer. Then send the answer to the role with `coord msg`.
48
+ If the escalation names a blocked task, run `coord unblock <id>` after you send the answer.
45
49
  - If the architect escalates a repeated review failure, forward the
46
50
  options to the user. Do not send a new goal until the user decides.
47
51
 
@@ -53,6 +57,7 @@ roles:
53
57
  - Process detail in reports. The user needs outcomes and decisions.
54
58
  - Optimistic status. Report blockers and failed checks as they are.
55
59
  model_hint: "Strongest reasoning and long context, conversational. Cloud: Claude Opus 5.5 (default for the claude harness), GPT-5.x."
60
+ effort: medium
56
61
  can_edit: false
57
62
 
58
63
  architect:
@@ -84,6 +89,8 @@ roles:
84
89
  Goal, Inputs, Out of scope, Acceptance, Report format.
85
90
  A worker drifts if a field is missing.
86
91
  - Write acceptance criteria as exact test files or commands. The worker runs each one before coord done.
92
+ - A message `BLOCKED task <id> (dependency)` waits for other work. Plan or order that work.
93
+ When it lands, run `coord unblock <id>`. A blocked task stays out of `coord next` until then.
87
94
  - If a review rejects the same kind of problem two times, stop the fix
88
95
  loop. Send the project manager two or three options with the cost of
89
96
  each option. A third patch of the same kind usually fails too.
@@ -96,6 +103,7 @@ roles:
96
103
  - Code in task specs. Describe behavior and constraints instead.
97
104
  - Trust in a worker report without a diff check.
98
105
  model_hint: "Strongest reasoning and long context. Cloud: Claude Opus 5.5 (default for the claude harness), GPT-5.x, deepseek-v4."
106
+ effort: high
99
107
  can_edit: false
100
108
 
101
109
  backend-developer:
@@ -123,6 +131,7 @@ roles:
123
131
  - Edits to a test that looks wrong. Report the test to the architect.
124
132
  - Claims about code that you did not read.
125
133
  model_hint: "A strong code model. A local Qwen3.8-27B is enough for well-scoped tasks; use a cloud coder for hard or large changes."
134
+ effort: medium
126
135
  can_edit: true
127
136
 
128
137
  frontend-developer:
@@ -149,6 +158,7 @@ roles:
149
158
  - Values that are hard-coded to make a test pass.
150
159
  - Visual changes outside the task scope.
151
160
  model_hint: "Same as backend-developer: local coder for scoped work, cloud coder for hard UI work."
161
+ effort: medium
152
162
  can_edit: true
153
163
 
154
164
  tester:
@@ -174,6 +184,7 @@ roles:
174
184
  - Assertions that only repeat the implementation.
175
185
  - Deleted or weakened tests. A test removal needs architect approval.
176
186
  model_hint: "Mechanical test work usually fits a local coder model; use cloud for subtle failure analysis."
187
+ effort: low
177
188
  can_edit: true
178
189
 
179
190
  reviewer:
@@ -216,6 +227,7 @@ roles:
216
227
  - Style comments that the project's linter already covers.
217
228
  - A warning or minor finding labeled critical to force another round.
218
229
  model_hint: "Judgment-heavy. Prefer a cloud model with strong review ability."
230
+ effort: high
219
231
  can_edit: false
220
232
 
221
233
  skeptic:
@@ -252,6 +264,7 @@ roles:
252
264
  - A warning or minor finding labeled critical to force another round.
253
265
  - Style comments. The reviewer and the linter cover style.
254
266
  model_hint: "Judgment-heavy. Use a different model family from reviewer and auditor, for example GPT-5.x if reviewer runs Claude. Different models miss different defects."
267
+ effort: high
255
268
  can_edit: false
256
269
 
257
270
  auditor:
@@ -286,4 +299,5 @@ roles:
286
299
  - A warning or minor finding labeled critical to force another round.
287
300
  - Style comments. The reviewer and the linter cover style.
288
301
  model_hint: "Judgment-heavy. Use a different model family from reviewer and skeptic, for example Gemini, Grok, or a strong local model. Different models miss different defects."
302
+ effort: high
289
303
  can_edit: false