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
data/lib/maf/retire.rb CHANGED
@@ -7,7 +7,12 @@ require_relative "shared/processes"
7
7
 
8
8
  module Maf
9
9
  class Retire
10
- RUNTIME_PREFIXES = %w[.maf/ .claude/ .opencode/ .codex/].freeze
10
+ NOT_OURS = "maf: pid %<pid>s is not the dispatcher of %<worker>s: another process has the pid now. " \
11
+ "maf does not stop it."
12
+ DISPATCHER_COMMAND = %r{(\A|[\s/])\.maf/bin/dispatcher(\s|\z)}
13
+
14
+ # Changes in these folders belong to the flow, not to the work of the worker.
15
+ def self.runtime_prefixes = [".maf/"] + Maf::Shared::Harness.all.filter_map(&:home_dir).map { |dir| "#{dir}/" }
11
16
 
12
17
  def initialize(root, spec)
13
18
  @root = root
@@ -38,12 +43,42 @@ module Maf
38
43
  def stop_dispatcher
39
44
  pid = Workers.at(@root).find(@worker)&.fetch("pid", nil)
40
45
  return unless pid && Shared::Processes.alive?(pid)
46
+ return warn(format(NOT_OURS, pid: pid, worker: @worker)) unless dispatcher?(pid)
41
47
 
42
48
  puts "Stopping the dispatcher of #{@worker} (pid #{pid}). A running agent finishes first."
43
49
  RunningProcesses.terminate(pid)
44
50
  abort "maf: pid #{pid} did not stop. Stop it with: kill #{pid}" unless RunningProcesses.wait_for_exit(pid)
45
51
  end
46
52
 
53
+ def dispatcher?(pid)
54
+ workers = Workers.at(@root)
55
+ return workers.dispatcher?(@worker) unless workers.started(@worker).to_s.empty?
56
+ return true if legacy_dispatcher?(pid)
57
+
58
+ abort "maf: maf cannot check pid #{pid}. Stop it with: kill #{pid}, then run this again." if beating?(pid)
59
+ false
60
+ end
61
+
62
+ def legacy_dispatcher?(pid)
63
+ command = IO.popen(["ps", "-o", "command=", "-p", pid.to_s], err: File::NULL, &:read).to_s.strip
64
+ command.match?(DISPATCHER_COMMAND) && RunningProcesses.in(@dir).include?(pid.to_i)
65
+ rescue SystemCallError
66
+ false
67
+ end
68
+
69
+ def beating?(pid)
70
+ presence = read_presence
71
+ presence["pid"].to_i == pid.to_i && Shared::Processes.recent?(presence["seen_at"])
72
+ end
73
+
74
+ def read_presence
75
+ JSON.parse(File.read(presence_path))
76
+ rescue SystemCallError, JSON::ParserError
77
+ {}
78
+ end
79
+
80
+ def presence_path = File.join(@root, ".maf", "coordination", "presence", "#{@worker}.json")
81
+
47
82
  def release_tasks
48
83
  env = { "COORD_ROLE" => role, "COORD_WORKER" => @worker }
49
84
  ids = Team.coord(@root, "next", "--mine", env: env).scan(/^[0-9a-f-]{36}/)
@@ -56,10 +91,7 @@ module Maf
56
91
  Team.coord(@root, "annotate", id, "Worker #{@worker} was retired. Continue on the existing task branch.")
57
92
  end
58
93
 
59
- def remove_presence
60
- coord_dir = File.join(@root, ".maf", "coordination")
61
- FileUtils.rm_f(File.join(coord_dir, "presence", "#{@worker}.json"))
62
- end
94
+ def remove_presence = FileUtils.rm_f(presence_path)
63
95
 
64
96
  def remove_worker
65
97
  remove_worktree
@@ -72,7 +104,11 @@ module Maf
72
104
  return false unless Dir.exist?(@dir)
73
105
 
74
106
  lines = IO.popen(["git", "-C", @dir, "status", "--porcelain"], err: File::NULL, &:readlines)
75
- lines.map { |line| line[3..].strip }.any? { |path| RUNTIME_PREFIXES.none? { |prefix| path.start_with?(prefix) } }
107
+ lines.map { |line| line[3..].strip }.any? do |path|
108
+ Retire.runtime_prefixes.none? do |prefix|
109
+ path.start_with?(prefix)
110
+ end
111
+ end
76
112
  end
77
113
 
78
114
  def remove_worktree
@@ -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
@@ -23,7 +23,7 @@ module Maf
23
23
  def self.detach(cmd, worker)
24
24
  log = File.join(ENV.fetch("COORD_DIR"), "sessions", "#{worker}.log")
25
25
  pid = spawn_detached(cmd, log)
26
- Maf::Workers.at(Project.root).update(worker, "pid" => pid)
26
+ Maf::Workers.at(Project.root).update(worker, "pid" => pid, "started" => Maf::Shared::Processes.started_at(pid))
27
27
  puts "Started #{worker} in the background (pid #{pid}). Log: #{log}"
28
28
  end
29
29
 
@@ -1,20 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../shared/sandbox"
4
+ require_relative "pty_relay"
5
+
3
6
  module Maf
4
7
  module SetupAgent
8
+ # Launcher replaces this process with the interactive session of a harness.
9
+ # The harness file builds the command (Maf::Shared::Harnesses).
5
10
  module Launcher
6
- REGISTRY = {}
11
+ def self.launch(harness, role, model, effort = nil)
12
+ ENV.update(harness.launch_env)
13
+ return exec_or_die(harness.launch(role, model, effort)) if Maf::Shared::Sandbox.mode == "off"
7
14
 
8
- def self.for(harness)
9
- register_defaults if REGISTRY.empty?
10
- REGISTRY.fetch(harness) { abort "setup_agent: no launcher for harness '#{harness}' yet." }
15
+ warn "maf: sandbox: #{Maf::Shared::Sandbox.describe(harness)}"
16
+ exit Maf::Shared::Sandbox.keeping_links(harness) { relay_or_die(sandboxed(harness, role, model, effort)) }
11
17
  end
12
18
 
13
- def self.register_defaults
14
- REGISTRY["claude"] = Claude
15
- REGISTRY["opencode"] = Opencode
16
- REGISTRY["codex"] = Codex
17
- REGISTRY["hermes"] = Hermes
19
+ def self.sandboxed(harness, role, model, effort)
20
+ Maf::Shared::Sandbox.wrap(harness.launch(role, model, effort), harness)
18
21
  end
19
22
 
20
23
  def self.exec_or_die(cmd)
@@ -23,62 +26,10 @@ module Maf
23
26
  abort "setup_agent: '#{cmd.first}' not found on PATH."
24
27
  end
25
28
 
26
- # FlowFiles finds the files of the flow in .maf/ of the main project. The
27
- # project's own harness config stays as it is: each file of the flow is
28
- # passed to the harness at the start.
29
- module FlowFiles
30
- def self.path(*parts) = File.join(Project.root, ".maf", *parts)
31
- def self.flag(name, *parts) = File.exist?(path(*parts)) ? [name, path(*parts)] : []
32
- end
33
-
34
- # --settings adds the hooks of the flow, --mcp-config adds the graph server.
35
- # --mcp-config takes many values, so `--` ends the options before the prompt.
36
- module Claude
37
- def self.launch(role, _worker, model)
38
- prompt = "Read .maf/agents/claude/#{role}.md and follow it exactly. Start your work loop now."
39
- cmd = ["claude", *FlowFiles.flag("--settings", "claude", "settings.json"),
40
- *FlowFiles.flag("--mcp-config", "mcp", "claude.json")]
41
- Launcher.exec_or_die(cmd + (model ? ["--model", model] : []) + ["--", prompt])
42
- end
43
- end
44
-
45
- # opencode merges the file in OPENCODE_CONFIG with the project's opencode.json.
46
- module Opencode
47
- def self.launch(role, _worker, model)
48
- config = FlowFiles.path("mcp", "opencode.json")
49
- ENV["OPENCODE_CONFIG"] = config if File.exist?(config)
50
- cmd = ["opencode", ".", "--agent", role, "--prompt", "Start your work loop now."]
51
- Launcher.exec_or_die(cmd + (model ? ["--model", model] : []))
52
- end
53
- end
54
-
55
- module Codex
56
- def self.launch(role, _worker, model)
57
- prompt_file = ".codex/prompts/#{role}.md"
58
- abort "setup_agent: #{prompt_file} missing." unless File.exist?(prompt_file)
59
-
60
- cmd = ["codex", *writable_dirs]
61
- cmd += ["--model", model] if model
62
- warn_untrusted
63
- Launcher.exec_or_die(cmd + [File.read(prompt_file)])
64
- end
65
-
66
- UNTRUSTED = "maf: Codex does not trust the hooks in %<file>s yet. Trust them when Codex asks. " \
67
- "Without the hooks, the session records no token usage, and coord await cannot wake it."
68
-
69
- # Codex runs the hooks of a project only after the user trusts them, and
70
- # a worktree is a new project for Codex. Codex records the trust in its config.toml.
71
- def self.warn_untrusted
72
- hooks = File.join(File.realpath(Dir.pwd), ".codex", "hooks.json")
73
- config = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
74
- trusted = File.exist?(config) && File.read(config).include?("#{hooks}:stop:")
75
- warn format(UNTRUSTED, file: hooks) if File.exist?(hooks) && !trusted
76
- end
77
-
78
- # The worktree is the only writable root of the Codex sandbox. coord
79
- # writes the board and the artifacts in .maf/coordination of the main
80
- # project, so Codex would ask for approval at each coord command.
81
- def self.writable_dirs = ["--add-dir", File.join(SetupAgent::Project.root, ".maf", "coordination")]
29
+ def self.relay_or_die(cmd)
30
+ PtyRelay.new(cmd).run
31
+ rescue Errno::ENOENT
32
+ abort "setup_agent: '#{cmd.first}' not found on PATH."
82
33
  end
83
34
  end
84
35
  end
@@ -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
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+ require "io/console"
5
+
6
+ module Maf
7
+ module SetupAgent
8
+ # PtyRelay runs a command on its own pseudo terminal and copies the input and the output.
9
+ class PtyRelay
10
+ CHUNK = 4096
11
+ DRAIN = 0.5
12
+ SIGNALS = %w[TERM HUP INT].freeze
13
+ EOF = "\x04"
14
+
15
+ def initialize(cmd) = @cmd = cmd
16
+
17
+ def run
18
+ output, input, pid = PTY.spawn(*@cmd)
19
+ SIGNALS.each { |name| Signal.trap(name) { pass(name, pid) } }
20
+ relay(output, input, Process.detach(pid))
21
+ ensure
22
+ [output, input].compact.each(&:close)
23
+ end
24
+
25
+ private
26
+
27
+ def relay(output, input, waiter)
28
+ resize(input) && Signal.trap("WINCH") { resize(input) }
29
+ copiers = [Thread.new { copy_input(input) }, Thread.new { copy(output, $stdout) }]
30
+ raw { waiter.join }
31
+ stop(copiers)
32
+ waiter.value.exitstatus || 1
33
+ end
34
+
35
+ def stop(copiers)
36
+ copiers.last.join(DRAIN)
37
+ copiers.each(&:kill)
38
+ end
39
+
40
+ def copy(from, to)
41
+ loop { to.write(from.readpartial(CHUNK)) && to.flush }
42
+ rescue IOError, Errno::EIO
43
+ nil
44
+ end
45
+
46
+ # The end of the input is no end on a terminal, so the command gets ^D. Else `echo task | maf start` waits.
47
+ # After a line without a newline, the first ^D only sends the line, so the command gets a second one.
48
+ def copy_input(input)
49
+ last = send_input(input)
50
+ input.write(last.end_with?("\n") ? EOF : EOF * 2)
51
+ rescue IOError, Errno::EIO
52
+ nil
53
+ end
54
+
55
+ def send_input(input)
56
+ last = "\n"
57
+ loop { input.write(last = $stdin.readpartial(CHUNK)) && input.flush }
58
+ rescue EOFError
59
+ last
60
+ end
61
+
62
+ def pass(name, pid)
63
+ Process.kill(name, pid)
64
+ rescue Errno::ESRCH
65
+ nil
66
+ end
67
+
68
+ def raw(&block) = $stdin.tty? ? $stdin.raw(&block) : yield
69
+
70
+ def resize(input)
71
+ $stdout.tty? && (input.winsize = $stdout.winsize)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -8,7 +8,7 @@ module Maf
8
8
  # A harness folder with the project's own files gets one link per role file.
9
9
  module RoleFile
10
10
  def self.copy(root, dir, harness, role)
11
- relative = Flow.role_path(harness, role)
11
+ relative = Maf::Shared::Harness.fetch(harness).role_path(role)
12
12
  return unless relative
13
13
 
14
14
  copy_missing(File.join(root, relative), File.join(dir, relative))
@@ -8,18 +8,7 @@ module Maf
8
8
  module RuntimeHooks
9
9
  def self.install(dir, harness)
10
10
  %w[next-task.rb board-watch.rb session-guard.rb context-watch.rb].each { |name| copy(dir, name) }
11
- configure(dir, harness)
12
- copy(dir, "board-watch-opencode.js", ".opencode/plugins/board-watch.js") if harness == "opencode"
13
- end
14
-
15
- # Claude Code reads the hooks from the settings file of the main project
16
- # (`maf start` passes it with --settings). The hook commands run in the
17
- # worktree, where the copies above live.
18
- def self.configure(dir, harness)
19
- root = Project.root
20
- settings = File.join(root, Bootstrap::CLAUDE_SETTINGS)
21
- Bootstrap::ClaudeSettings.new(Bootstrap::Project.new(root, false)).configure(settings) if harness == "claude"
22
- Flow::CodexHooks.new(dir).install if harness == "codex"
11
+ Maf::Shared::Harness.fetch(harness).runtime_hooks(dir)
23
12
  end
24
13
 
25
14
  def self.copy(dir, name, relative = ".maf/coordination/harness-hooks/#{name}")
@@ -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.for(args.harness).launch(args.role, args.worker, 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
@@ -104,8 +104,6 @@ require_relative "setup_agent/role_file"
104
104
  require_relative "setup_agent/manifest"
105
105
  require_relative "setup_agent/worktree"
106
106
  require_relative "setup_agent/launcher"
107
- require_relative "setup_agent/hermes_launcher"
108
107
  require_relative "setup_agent/project"
109
- require_relative "setup_agent/hermes_skill"
110
108
  require_relative "setup_agent/runtime_hooks"
111
109
  require_relative "../../assets/harness-hooks/session-guard"
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require "erb"
7
+ require "json"
8
+ require_relative "defaults"
9
+ require_relative "output"
10
+
11
+ module Maf
12
+ module Shared
13
+ # Harness is the registry of the agent harnesses. Each file in harnesses/
14
+ # defines one harness: a subclass of Base. The rest of maf asks the
15
+ # registry and never names a harness.
16
+ module Harness
17
+ def self.register(klass) = (@classes ||= []) << klass
18
+ def self.all = @all ||= @classes.map(&:new).sort_by(&:name)
19
+ def self.names = all.map(&:name)
20
+ def self.[](name) = all.find { |harness| harness.name == name }
21
+ def self.fetch(name) = self[name] || raise(KeyError, "unknown harness '#{name}' (use #{names.join(", ")})")
22
+ def self.known?(name) = !self[name].nil?
23
+
24
+ def self.on_path?(command)
25
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
26
+ end
27
+
28
+ # Base is the interface of a harness. A harness file overrides the
29
+ # methods that its CLI needs. Every other method keeps the default here.
30
+ # The methods are grouped by the part of maf that calls them.
31
+ class Base
32
+ def self.inherited(klass)
33
+ super
34
+ Harness.register(klass)
35
+ end
36
+
37
+ # The harness name in `maf add HARNESS:ROLE`. It comes from the class name.
38
+ def name = self.class.name.split("::").last.downcase
39
+ def to_s = name
40
+
41
+ # The CLI of the harness. `maf add` offers only the installed harnesses.
42
+ def command = name
43
+ def installed? = Harness.on_path?(command)
44
+
45
+ include RoleFiles
46
+ include Setup
47
+ include Run
48
+
49
+ # The tool calls of one session, for the analyst: [[name, command, size], ...].
50
+ def transcript_calls(_session_id) = []
51
+
52
+ # The JSON objects of a transcript file, one per line. A broken line is skipped.
53
+ def jsonl(path) = path ? File.foreach(path).filter_map { |line| Events.parse(line.strip) } : []
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require_relative "../project"
7
+ require_relative "vm_credential"
8
+
9
+ module Maf
10
+ module Shared
11
+ module Harness
12
+ AGENTS_DIR = File.join(".maf", "agents")
13
+ # The command of the graphify MCP server. It runs in the project folder.
14
+ VAULT_MCP = %w[ruby .maf/bin/vault mcp].freeze
15
+
16
+ # RoleFiles: maf add writes one role file per agent.
17
+ module RoleFiles
18
+ # The folder where the harness reads role files. maf links it to
19
+ # .maf/agents/<name>/. nil: the harness reads no role folder.
20
+ def role_dir = nil
21
+ def role_path(role) = role_dir && File.join(AGENTS_DIR, name, "#{role}.md")
22
+
23
+ # settings: the data of .maf/config.json. A harness that keeps role
24
+ # files outside the project reads its folder there.
25
+ def role_destination(project, role, _settings = {}) = role_path(role) && File.join(project, role_path(role))
26
+
27
+ # An ERB template. It gets role, title, description, can_edit, project, prompt, model, and effort.
28
+ def role_template = raise(NotImplementedError, "#{name}: role_template")
29
+ def render_role(values) = ERB.new(role_template, trim_mode: "-").result_with_hash(effort: nil, **values)
30
+
31
+ # nil: the harness lists no models, so every name counts as known.
32
+ def models = nil
33
+ # list: the models of the harness. A caller with a cached list passes it.
34
+ def known_model?(model, list = models) = list.nil? || list.include?(model)
35
+ def default_model = nil
36
+
37
+ # The command that sets the model, for a harness whose role file cannot set it.
38
+ def model_hint(_model) = nil
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
+
46
+ # How an idle agent waits for work. :watch - a watcher wakes the idle
47
+ # session. :await - the stop hook waits after `coord await`.
48
+ # :poll - the agent blocks in `coord next --wait`.
49
+ def wait = :poll
50
+
51
+ # One more rule line for the architect prompt, or nil.
52
+ def architect_rule = nil
53
+ end
54
+
55
+ # Setup: maf add, maf start, bootstrap, untrack, retire, and uninstall.
56
+ module Setup
57
+ # The graphify MCP server in a config file of the flow:
58
+ # { file: ".maf/mcp/x.json", key: "mcpServers", entry: {...} }. nil: no file.
59
+ def mcp = nil
60
+ # The command that adds the server to a global config of the harness, or nil.
61
+ def mcp_command(_server, _vault) = nil
62
+ # The project file where an older maf wrote the MCP server, or nil.
63
+ def legacy_mcp_file = nil
64
+
65
+ # maf add installs the hooks. A truthy result: the user has steps left.
66
+ def install_hooks(_project) = nil
67
+ # maf start refreshes the hooks in the worktree before the session starts.
68
+ def runtime_hooks(_worktree) = nil
69
+ # Bootstrap installs the hooks of the harness only if this folder exists.
70
+ def bootstrap_dir = nil
71
+
72
+ # Uninstall: objects with #steps that remove the files of the flow.
73
+ def cleaners(_project) = []
74
+ # Uninstall and untrack: objects with #steps that remove what an older
75
+ # maf wrote into the project's own files.
76
+ def legacy_cleaners(_project) = []
77
+ # Files outside the project that uninstall keeps: other projects can use them.
78
+ def global_files = []
79
+
80
+ def vm_install = nil
81
+ def vm_credentials = []
82
+ def vm_login_hint = "maf vm shell, then start #{command} and log in"
83
+
84
+ # The top folder of the harness. maf retire ignores changes in it.
85
+ def home_dir = role_dir&.split("/")&.first
86
+ # Files that the flow writes in a worktree. coord keeps them out of git.
87
+ def runtime_files = []
88
+ # Folders that uninstall removes when they are empty. Children come first.
89
+ def empty_dirs = [role_dir, home_dir].compact.uniq
90
+
91
+ # The paths that git tracks for the flow. tracked.call(path) lists the
92
+ # tracked files below path. By default, the links into .maf/agents/.
93
+ def untrack_paths(root, tracked)
94
+ return [] unless role_dir
95
+
96
+ tracked.call(role_dir).select do |path|
97
+ File.symlink?(File.join(root, path)) && File.readlink(File.join(root, path)).include?(AGENTS_DIR)
98
+ end
99
+ end
100
+ end
101
+
102
+ # Run: maf start (an interactive session) and the dispatcher (one run per task).
103
+ module Run
104
+ # The command of an interactive session.
105
+ def launch(_role, _model, _effort = nil) = raise(NotImplementedError, "#{name}: launch")
106
+ def launch_env = {}
107
+
108
+ def sandbox_dirs = []
109
+ def sandbox_state = []
110
+ def sandbox_prefixes = []
111
+ def sandbox_protected = []
112
+ def sandbox_project = []
113
+ def in_dir(dir, names) = names.map { |name| File.join(dir, name) }
114
+ def sandbox_placeholders = sandbox_protected.reject { |path| path.end_with?(".md", ".yaml") }
115
+ def sandboxed(cmd) = cmd
116
+
117
+ # The command of one dispatched run. session_id is nil for a fresh session.
118
+ def build_command(_config, _prompt, _session_id) = raise(NotImplementedError, "#{name}: build_command")
119
+ def run_env(_config) = {}
120
+ # The dispatcher calls it once after it parses the options.
121
+ def prepare(config) = config
122
+ # Seconds that the prompt cache of the harness stays warm, or nil for the default.
123
+ def cache_window = nil
124
+ def peak_rate?(_config) = false
125
+
126
+ # The parts of the run output. nil: the harness does not report it.
127
+ def stale_session?(_output) = false
128
+ def session_id(_output) = nil
129
+ def result_text(_output) = nil
130
+ def usage(_output) = nil
131
+ def context(_output) = nil
132
+ def model(_output) = nil
133
+
134
+ # A file of the flow in .maf/ of the main checkout. The dispatcher finds
135
+ # .maf/ next to its coordination folder, which COORD_DIR can move.
136
+ def flow_file(*parts) = File.join(Project.root, ".maf", *parts)
137
+ def run_file(config, *parts) = File.join(File.dirname(config.coord_dir), *parts)
138
+ def flag_if(flag, path) = File.exist?(path) ? [flag, path] : []
139
+
140
+ def present(value) = value.to_s.empty? ? nil : value
141
+ def model_flag(model, flag = "--model") = model ? [flag, model] : []
142
+ def effort_flag(effort, flag = "--effort") = effort ? [flag, effort] : []
143
+ def resume(session_id, *flag) = session_id ? [*flag, session_id] : []
144
+ end
145
+ end
146
+ end
147
+ end