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
@@ -151,7 +151,8 @@ module ContextWatch
151
151
  # maf start gives the role file in the first prompt, not as a system
152
152
  # prompt. /clear drops it, so a cleared session gets the role file again.
153
153
  # The hook puts the file text in the context: a Read costs one model call.
154
- ROLE_FILES = { "claude" => ".maf/agents/claude/%s.md", "codex" => ".codex/prompts/%s.md" }.freeze
154
+ # The flow keeps the role file of each harness in .maf/agents/<harness>/.
155
+ def role_path(harness, role) = File.join(".maf", "agents", harness, "#{role}.md")
155
156
 
156
157
  def session_start
157
158
  @store.save("session_id" => @input["session_id"], "asked" => nil)
@@ -170,10 +171,10 @@ module ContextWatch
170
171
  end
171
172
 
172
173
  # The harness guess comes first. The file that exists in the worktree wins.
174
+ # The role file of this harness first, then the file of another harness of the role.
173
175
  def role_file
174
- names = ROLE_FILES.keys.sort_by { |name| name == harness ? 0 : 1 }
175
- paths = names.map { |name| format(ROLE_FILES[name], @env["COORD_ROLE"]) }
176
- paths.find { |file| File.exist?(file) } || paths.first
176
+ own = role_path(harness, @env["COORD_ROLE"])
177
+ File.exist?(own) ? own : Dir.glob(role_path("*", @env["COORD_ROLE"])).min || own
177
178
  end
178
179
 
179
180
  def note_text
@@ -0,0 +1,61 @@
1
+ minimumLimaVersion: 2.2.0
2
+
3
+ base:
4
+ - template:_images/ubuntu-26.04
5
+
6
+ vmType: <%= vm_type %>
7
+ mountType: <%= mount_type %>
8
+ cpus: <%= cpus %>
9
+ memory: "<%= memory %>GiB"
10
+ disk: "<%= disk %>GiB"
11
+
12
+ mounts:
13
+ # JSON quoting is valid YAML, so a quote in the path stays in the path.
14
+ - location: <%= project.to_json %>
15
+ writable: true
16
+
17
+ containerd:
18
+ system: false
19
+ user: false
20
+
21
+ provision:
22
+ - mode: system
23
+ script: |
24
+ #!/bin/bash
25
+ set -eux -o pipefail
26
+ export DEBIAN_FRONTEND=noninteractive
27
+ command -v maf >/dev/null 2>&1 && exit 0
28
+ apt-get update
29
+ apt-get install -y ruby-full git tmux curl bubblewrap nodejs npm build-essential cmake cargo rustc uuid-dev libsqlite3-dev
30
+ cd /tmp
31
+ curl -fsSL -o task.tar.gz <%= taskwarrior_url %>
32
+ echo "<%= taskwarrior_sha256 %> task.tar.gz" | sha256sum -c -
33
+ tar xzf task.tar.gz && cd task-<%= taskwarrior %>
34
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$(nproc)" && cmake --install build
35
+ cd / && rm -rf /tmp/task.tar.gz /tmp/task-<%= taskwarrior %> /root/.cargo/registry && apt-get clean
36
+ gem install <%= gem_args %>
37
+ <% harness_installs.each do |name, (cli, install)| -%>
38
+ - mode: user
39
+ script: |
40
+ #!/bin/bash
41
+ set -eux -o pipefail
42
+ export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH"
43
+ command -v <%= cli %> >/dev/null 2>&1 && exit 0
44
+ <%= install %>
45
+ <% end -%>
46
+ - mode: user
47
+ script: |
48
+ #!/bin/bash
49
+ grep -q 'maf vm' ~/.bashrc && exit 0
50
+ echo 'export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH" # maf vm' >> ~/.bashrc
51
+
52
+ probes:
53
+ - description: maf is installed
54
+ script: |
55
+ #!/bin/bash
56
+ set -eux -o pipefail
57
+ if ! timeout 1800s bash -c "until command -v maf >/dev/null 2>&1; do sleep 5; done"; then
58
+ echo >&2 "maf is not installed yet"
59
+ exit 1
60
+ fi
61
+ hint: See /var/log/cloud-init-output.log in the VM.
data/install.md CHANGED
@@ -198,7 +198,12 @@ list with the existing entries plus the new entry.
198
198
  The second command approves the hook one time. Hermes stores the consent for this
199
199
  version of the script. A new script version needs a new approval.
200
200
 
201
- Confirm that every check from `hermes hooks doctor` passes. Then continue.
201
+ Confirm that every check from `hermes hooks doctor` passes.
202
+
203
+ Hermes gives the commands of an agent only the variables in
204
+ `terminal.client_env_passthrough`. If `maf add` prints a `hermes config set
205
+ terminal.client_env_passthrough` command, run it. It keeps the names that the
206
+ list has now. Then continue.
202
207
 
203
208
  ## Step 5c - Set the Gemini key for the doc-graph refresh
204
209
 
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Maf
4
4
  module Bootstrap
5
- # ClaudeSettings plans and applies the Claude Code hooks in the settings
6
- # file of the flow, .maf/claude/settings.json. `maf start` passes the file
7
- # with --settings. Claude Code runs these hooks next to the project's own.
5
+ # ClaudeSettings plans and applies the Claude Code hooks and environment in
6
+ # the settings file of the flow, .maf/claude/settings.json. `maf start`
7
+ # passes the file with --settings. Claude Code runs these hooks next to the
8
+ # project's own. The --settings env wins over the env of the user settings.
8
9
  class ClaudeSettings
9
10
  def initialize(project)
10
11
  @project = project
@@ -12,23 +13,30 @@ module Maf
12
13
 
13
14
  def plan
14
15
  file = @project.path(CLAUDE_SETTINGS)
15
- [@project.action(status(file), file, "#{file} (next-task + board-watch hooks)", :claude_stop_hook)]
16
+ [@project.action(status(file), file, "#{file} (next-task + board-watch hooks, env)", :claude_stop_hook)]
16
17
  end
17
18
 
18
19
  def configure(file)
19
20
  settings = read(file)
20
- missing = missing_hooks(settings)
21
- missing.each { |event, hook| add_hook(settings, event, hook) }
22
- write(file, settings) unless missing.empty?
23
- !missing.empty?
21
+ return false if current?(settings)
22
+
23
+ write(file, update(settings))
24
+ true
24
25
  end
25
26
 
26
27
  private
27
28
 
28
29
  def status(file)
29
- File.exist?(file) && missing_hooks(read(file)).empty? ? :skip : :configure_claude_hook
30
+ File.exist?(file) && current?(read(file)) ? :skip : :configure_claude_hook
31
+ end
32
+
33
+ def update(settings)
34
+ missing_hooks(settings).each { |event, hook| add_hook(settings, event, hook) }
35
+ settings.merge("env" => (settings["env"] || {}).merge(CLAUDE_ENV))
30
36
  end
31
37
 
38
+ def current?(settings) = missing_hooks(settings).empty? && (settings["env"] || {}) >= CLAUDE_ENV
39
+
32
40
  def read(file)
33
41
  File.exist?(file) ? (JSON.parse(File.read(file)) rescue {}) : {}
34
42
  end
@@ -20,7 +20,7 @@ module Maf
20
20
 
21
21
  # The shared library that the scripts load. Each file is planned as a script.
22
22
  def shared
23
- Dir.children(File.join(@project.assets, SHARED_SOURCE)).sort.map do |name|
23
+ Dir.glob("**/*.rb", base: File.join(@project.assets, SHARED_SOURCE)).sort.map do |name|
24
24
  script(File.join(SHARED_SOURCE, name), SHARED_SIGNATURE, @project.path(MAF_DIR, SHARED_DIR, name))
25
25
  end
26
26
  end
data/lib/maf/bootstrap.rb CHANGED
@@ -64,6 +64,10 @@ module Maf
64
64
  end
65
65
  ].freeze
66
66
 
67
+ # Claude Code environment of a flow session. Agent teams stay off: with
68
+ # them on, a subagent that Claude names starts as a teammate.
69
+ CLAUDE_ENV = { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" => "0" }.freeze
70
+
67
71
  # The knowledge graph and the Obsidian vault, at the project root where graphify looks by default.
68
72
  GRAPH_DIR = "graphify-out"
69
73
 
data/lib/maf/cli.rb CHANGED
@@ -12,7 +12,9 @@ require_relative "menu"
12
12
  require_relative "team"
13
13
  require_relative "team_command"
14
14
  require_relative "worker_control"
15
+ require_relative "worker_list"
15
16
  require_relative "untrack"
17
+ require_relative "vm"
16
18
  require_relative "version"
17
19
 
18
20
  module Maf
@@ -20,6 +22,7 @@ module Maf
20
22
 
21
23
  def self.flow(*args) = Flow::Generator.new(["--project", Dir.pwd, *args]).run
22
24
  def self.role_names = Flow::RoleCatalog.new(Dir.pwd).roles.keys
25
+ def self.role_effort(role) = Flow::RoleCatalog.new(Dir.pwd).roles.dig(role, "effort")
23
26
 
24
27
  def self.agents = File.exist?(MANIFEST) ? JSON.parse(File.read(MANIFEST)).fetch("agents") : []
25
28
  def self.agent_specs = agents.map { |a| "#{a["harness"]}:#{a["role"]}" }
@@ -37,23 +40,26 @@ module Maf
37
40
 
38
41
  class CLI
39
42
  COMMANDS = {
40
- "add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] add agents and install the flow; " \
41
- "without arguments it asks",
43
+ "add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] [--effort ROLE=LEVEL] add agents and install " \
44
+ "the flow; without arguments it asks",
42
45
  "remove" => "HARNESS:ROLE ... remove agents",
43
46
  "update" => " regenerate the files of the current agents",
44
47
  "agents" => " list the current agents",
45
48
  "roles" => " list the built-in roles and the project roles",
46
49
  "role" => "add NAME add a stub role to .maf/roles.yml",
47
- "start" => "[HARNESS ROLE[_WORKER]] [--dispatch [--detach]] start one agent in its worktree",
48
- "prepare" => "HARNESS ROLE[_WORKER] [--dispatch|--interactive] [--replace W] prepare a worker; " \
50
+ "start" => "[HARNESS ROLE[_WORKER]] [--effort LEVEL] [--dispatch [--detach]] start one agent in its worktree",
51
+ "prepare" => "HARNESS ROLE[_WORKER] [--effort LEVEL] [--dispatch|--interactive] [--replace W] " \
52
+ "prepare a worker; " \
49
53
  "--dispatch starts it (default for the architect)",
50
54
  "retire" => "ROLE[_WORKER] remove a worker; its tasks return to the pool",
51
- "worker" => "status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N] [--max-session-runs N] " \
52
- "[--cache-window S] control one worker; start and restart save the session limits of the role",
55
+ "worker" => "list | status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N] " \
56
+ "[--max-session-runs N] [--cache-window S] list the running workers, or control one worker; " \
57
+ "start and restart save the session limits of the role",
53
58
  "team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
54
59
  "uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
55
60
  "migrate" => "[--check] [--yes] move an old-layout install into .maf/",
56
61
  "untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
62
+ "vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
57
63
  "menu" => " interactive mode (also: maf without a command)",
58
64
  "guide" => " print the setup guide for an AI agent (install.md)",
59
65
  "version" => " print the maf version (also: --version)"
@@ -93,7 +99,10 @@ module Maf
93
99
  # Without arguments on a terminal, maf add asks for the harness, the roles, and the models.
94
100
  def run_add = @args.empty? && $stdin.tty? ? Menu.new.add : Maf.flow(*AgentArgs.convert(@args, "--agent"))
95
101
  def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
96
- def run_update = Maf.flow(*@args)
102
+ def run_update
103
+ Maf.flow(*@args)
104
+ Untrack.warn_tracked(SetupAgent::Project.root)
105
+ end
97
106
  def run_roles = Maf.flow("--list-roles")
98
107
 
99
108
  def run_role
@@ -106,17 +115,20 @@ module Maf
106
115
  def run_prepare = Prepare.new(@args).run
107
116
  def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
108
117
  def run_worker
118
+ return WorkerList.new(SetupAgent::Project.root).run if @args.first == "list"
119
+
109
120
  spec = @args[1] || abort(worker_usage)
110
121
  limits = RoleLimits.parse(@args)
111
122
  WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
112
123
  .run(@args[0].to_s)
113
124
  end
114
125
 
115
- def worker_usage = "usage: maf worker #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
126
+ def worker_usage = "usage: maf worker list | #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
116
127
  "[--max-context N] [--max-session-runs N] [--cache-window S]"
117
128
  def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
118
129
  def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
119
130
  def run_menu = Menu.new.run
131
+ def run_vm = Vm.new(@args, SetupAgent::Project.root).run
120
132
  def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
121
133
  def run_version = puts("maf #{VERSION}")
122
134
  def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
@@ -129,7 +141,8 @@ module Maf
129
141
  end
130
142
 
131
143
  def agent_line(agent)
132
- "#{agent["harness"]}:#{agent["role"]}".ljust(32) + agent["model"].to_s
144
+ effort = agent["effort"] && "effort=#{agent["effort"]}"
145
+ "#{agent["harness"]}:#{agent["role"]}".ljust(32) + [agent["model"], effort].compact.join(" ")
133
146
  end
134
147
  end
135
148
  end
@@ -13,7 +13,7 @@ module Maf
13
13
  # Returns :create, :skip, or :refuse. :refuse means the folder holds files
14
14
  # that the flow does not own, or a symlink to another place.
15
15
  def link(harness)
16
- path = File.join(@project, HARNESS_DIRS.fetch(harness))
16
+ path = File.join(@project, Harness.fetch(harness).role_dir)
17
17
  return exclude(path) && :skip if linked?(path, harness)
18
18
  return :refuse unless free?(path)
19
19
 
@@ -28,7 +28,7 @@ module Maf
28
28
  # relative symlink in it instead, so the harness still finds the role.
29
29
  # A user file with the same name stays, and its name is returned.
30
30
  def link_files(harness)
31
- dir = File.join(@project, HARNESS_DIRS.fetch(harness))
31
+ dir = File.join(@project, Harness.fetch(harness).role_dir)
32
32
  prune(dir, harness)
33
33
  role_files(harness).reject { |source| link_file(dir, source) }.map { |source| File.basename(source) }
34
34
  end
@@ -18,12 +18,12 @@ module Maf
18
18
 
19
19
  private
20
20
 
21
- # Bootstrap adds the Claude Code hooks only if .claude/ exists, and the
22
- # opencode plugin only if .opencode/ exists. Flow writes the role files
23
- # after bootstrap, so create these dirs first.
21
+ # Bootstrap installs the hooks of a harness only if its folder exists
22
+ # (Harness#bootstrap_dir). Flow writes the role files after bootstrap,
23
+ # so create these dirs first.
24
24
  def make_harness_dirs
25
- used = %w[claude opencode] & @options.agents.map { |a| a[:harness] }
26
- used.each { |harness| FileUtils.mkdir_p(File.join(@options.project, ".#{harness}")) }
25
+ dirs = @options.agents.map { |a| Harness.fetch(a[:harness]).bootstrap_dir }.compact.uniq
26
+ dirs.each { |dir| FileUtils.mkdir_p(File.join(@options.project, dir)) }
27
27
  end
28
28
 
29
29
  def command
@@ -10,6 +10,14 @@ module Maf
10
10
  FILE = ".codex/hooks.json"
11
11
  TIMEOUT = { "timeout" => 3600 }.freeze
12
12
 
13
+ # A hooks file with only the flow hooks belongs to the flow.
14
+ def self.flow_only?(path)
15
+ hooks = JSON.parse(File.read(path)).fetch("hooks", {}).values.flatten.flat_map { |e| e.fetch("hooks", []) }
16
+ (hooks.map { |hook| hook["command"] } - COMMANDS).empty?
17
+ rescue JSON::ParserError, SystemCallError
18
+ false
19
+ end
20
+
13
21
  def initialize(project)
14
22
  @project = project
15
23
  @path = File.join(project, FILE)
@@ -25,8 +33,11 @@ module Maf
25
33
  private
26
34
 
27
35
  def save(data)
36
+ text = JSON.pretty_generate(data)
37
+ return if File.exist?(@path) && File.read(@path) == text
38
+
28
39
  FileUtils.mkdir_p(File.dirname(@path))
29
- File.write(@path, JSON.pretty_generate(data))
40
+ File.write(@path, text)
30
41
  end
31
42
 
32
43
  # A project that tracks its own Codex hooks keeps the file in git.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # EffortReport warns about an effort level that the harness does not list.
6
+ module EffortReport
7
+ def self.print(results)
8
+ list = results.select { |r| r[:effort] && !harness(r).known_effort?(r[:effort], r[:model]) }
9
+ return if list.empty?
10
+
11
+ puts "", "WARNING: the harness does not list these effort levels. Check the level for a typo:",
12
+ list.map { |r| " #{name(r)} -> #{r[:effort]}" },
13
+ " Fix one with: maf add HARNESS:ROLE --effort ROLE=LEVEL"
14
+ end
15
+
16
+ def self.harness(result) = Harness.fetch(result[:agent][:harness])
17
+ def self.name(result) = "#{result[:agent][:harness]}:#{result[:agent][:role]}"
18
+ end
19
+ end
20
+ end
@@ -26,6 +26,7 @@ module Maf
26
26
  Validator.project!(@options)
27
27
  @catalog = RoleCatalog.new(@options.project)
28
28
  @roles = @catalog.roles
29
+ @options.role_efforts = @roles.transform_values { |data| data["effort"] }
29
30
  end
30
31
 
31
32
  def generate_all
@@ -37,7 +38,7 @@ module Maf
37
38
  end
38
39
 
39
40
  def finish(results)
40
- pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install
41
+ pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install.any?
41
42
  manifest.write
42
43
  Report.new(@options.project, @roles, pending).print(results)
43
44
  end
@@ -51,7 +52,7 @@ module Maf
51
52
 
52
53
  def print_role(key, data)
53
54
  puts "", " #{key} (#{@catalog.source(key)})", " #{data.fetch("description")}",
54
- " model hint: #{data.fetch("model_hint")}"
55
+ " model hint: #{data.fetch("model_hint")}", *(" effort: #{data["effort"]}" if data["effort"])
55
56
  end
56
57
 
57
58
  def finish_without_agents
@@ -18,8 +18,8 @@ module Maf
18
18
 
19
19
  private
20
20
 
21
- def harnesses = @options.agents.map { |a| a[:harness] }.uniq.select { |h| HARNESS_DIRS.key?(h) }
22
- def dir(harness) = HARNESS_DIRS.fetch(harness)
21
+ def harnesses = @options.agents.map { |a| a[:harness] }.uniq.select { |h| Harness.fetch(h).role_dir }
22
+ def dir(harness) = Harness.fetch(harness).role_dir
23
23
 
24
24
  def link_role_files(harness)
25
25
  return warn_not_linked(harness) if File.symlink?(File.join(@options.project, dir(harness)))
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # HermesEnvSetup prints the step that lets Hermes give the flow variables to
6
+ # the commands of an agent. The flow never edits the Hermes config.
7
+ class HermesEnvSetup
8
+ KEY = "terminal.client_env_passthrough"
9
+ NAMES = %w[COORD_DIR COORD_ROLE COORD_WORKER COORD_DISPATCHED COORD_SLOT COORD_SESSION_PID TASKRC
10
+ MAF_BIN].freeze
11
+
12
+ def initialize(config_yaml = File.join(Dir.home, ".hermes", "config.yaml"))
13
+ @config_yaml = config_yaml
14
+ end
15
+
16
+ # Returns true when the user has a step left.
17
+ def install
18
+ names = listed
19
+ return puts(" hermes env ready: #{KEY}") || false if (NAMES - names).empty?
20
+
21
+ print_step(names) || true
22
+ end
23
+
24
+ def command(names) = "hermes config set #{KEY} '#{JSON.generate((names + NAMES).uniq)}'"
25
+
26
+ def listed
27
+ list = config.dig("terminal", "client_env_passthrough")
28
+ list.is_a?(Array) ? list.map(&:to_s) : []
29
+ end
30
+
31
+ private
32
+
33
+ def print_step(names)
34
+ puts "", " Hermes env: the commands of a Hermes agent get no COORD_* variables yet.",
35
+ " Do this step one time. It keeps the names that the list has now.", "", " #{command(names)}", ""
36
+ end
37
+
38
+ def config
39
+ data = YAML.safe_load_file(@config_yaml)
40
+ data.is_a?(Hash) ? data : {}
41
+ rescue SystemCallError, Psych::Exception
42
+ {}
43
+ end
44
+ end
45
+ end
46
+ end
@@ -4,6 +4,12 @@ module Maf
4
4
  module Flow
5
5
  # HookFiles copies a hook script from the assets and makes it executable.
6
6
  module HookFiles
7
+ # Installs a hook script of the assets in .maf/coordination/harness-hooks/ of the project.
8
+ def self.install(project, name)
9
+ dest = File.join(project, ".maf", "coordination", "harness-hooks", name)
10
+ copy(File.join(ASSETS, "harness-hooks", name), dest)
11
+ end
12
+
7
13
  def self.copy(src, dest)
8
14
  FileUtils.mkdir_p(File.dirname(dest))
9
15
  return if File.exist?(dest) && File.read(dest) == File.read(src)
@@ -3,31 +3,17 @@
3
3
  module Maf
4
4
  module Flow
5
5
  # HookInstaller installs the harness hooks that pick up tasks when a
6
- # session ends. Claude Code and opencode get theirs from bootstrap.
6
+ # session ends. Each harness installs its own (Harness#install_hooks).
7
+ # Some harnesses get theirs from bootstrap instead (Harness#bootstrap_dir).
7
8
  class HookInstaller
8
9
  def initialize(agents, project)
9
- @harnesses = agents.map { |a| a[:harness] }.uniq
10
+ names = agents.map { |a| a[:harness] }
11
+ @harnesses = Harness.all.select { |harness| names.include?(harness.name) }
10
12
  @project = project
11
13
  end
12
14
 
13
- # Returns true when the Hermes hook needs steps from the user.
14
- def install
15
- install_codex if @harnesses.include?("codex")
16
- @harnesses.include?("hermes") && HermesHookSetup.new.install
17
- end
18
-
19
- private
20
-
21
- def install_codex
22
- %w[next-task.rb session-guard.rb].each { |name| copy_hook(name) }
23
- CodexHooks.new(@project).install
24
- LegacyCodexHook.new.remove
25
- end
26
-
27
- def copy_hook(name)
28
- HookFiles.copy(File.join(ASSETS, "harness-hooks", name),
29
- File.join(@project, ".maf", "coordination", "harness-hooks", name))
30
- end
15
+ # Returns the result of each harness. A truthy result: the hook needs steps from the user.
16
+ def install = @harnesses.map { |harness| harness.install_hooks(@project) }
31
17
  end
32
18
  end
33
19
  end
@@ -24,9 +24,9 @@ module Maf
24
24
 
25
25
  def data
26
26
  data = kept_keys.merge("generated_at" => Time.now.utc.iso8601, "agents" => @options.agents.map { |a| entry(a) })
27
- # Only record hermes_dir when it differs from the default: the default is
27
+ # Only record hermes_dir when --hermes-dir sets it: the default is
28
28
  # an absolute home path that would leak into a committed manifest.
29
- data["hermes_dir"] = @options.hermes_dir unless @options.hermes_dir == DEFAULT_HERMES_DIR
29
+ data["hermes_dir"] = @options.hermes_dir if @options.hermes_dir
30
30
  data
31
31
  end
32
32
 
@@ -34,7 +34,7 @@ module Maf
34
34
  # a role without the role definitions.
35
35
  def entry(agent)
36
36
  { harness: agent[:harness], role: agent[:role], model: @options.model_for(agent),
37
- can_edit: @roles.fetch(agent[:role]).fetch("can_edit") }
37
+ can_edit: @roles.fetch(agent[:role]).fetch("can_edit") }.merge({ effort: @options.effort_for(agent) }.compact)
38
38
  end
39
39
 
40
40
  # Other tools own other keys (maf team: "team"; users: "base_branch").
@@ -3,36 +3,22 @@
3
3
  module Maf
4
4
  module Flow
5
5
  # McpConfig writes the graphify MCP server into a config file of the flow,
6
- # in .maf/mcp/. The project's .mcp.json and opencode.json stay as they are.
7
- # `maf start` and the dispatcher pass the file: Claude Code reads it with
8
- # --mcp-config, opencode with the OPENCODE_CONFIG variable.
6
+ # in .maf/mcp/. The project's own config files stay as they are. maf start
7
+ # and the dispatcher pass the file to the harness (Harness#launch and
8
+ # Harness#build_command). Harness#mcp names the file and the entry.
9
9
  # The server runs `vault mcp`. A foreign entry with the same name stays.
10
- # Codex and Hermes keep MCP servers in a global user file. maf does not
11
- # edit that file. It prints the command that adds the server instead.
10
+ # A harness with a global MCP config gives a command instead
11
+ # (Harness#mcp_command). maf does not edit that file. It prints the command.
12
12
  class McpConfig
13
13
  NAME = "graphify"
14
- ARGS = %w[ruby .maf/bin/vault mcp].freeze
15
- SCHEMA = "https://opencode.ai/config.json"
16
- TARGETS = {
17
- "claude" => { file: ".maf/mcp/claude.json", key: "mcpServers",
18
- entry: { "command" => ARGS.first, "args" => ARGS.drop(1) } },
19
- "opencode" => { file: ".maf/mcp/opencode.json", key: "mcp",
20
- entry: { "type" => "local", "command" => ARGS, "enabled" => true } }
21
- }.freeze
22
-
23
- def self.targets = TARGETS
24
-
25
- GLOBAL_COMMANDS = { "codex" => "codex mcp add %<name>s -- ruby %<vault>s mcp",
26
- "hermes" => "hermes mcp add %<name>s --command ruby --args %<vault>s mcp" }.freeze
27
14
 
28
15
  # The shell command for a harness with a global MCP config, or nil.
29
16
  def self.global_command(harness, project)
30
- vault = File.join(project, ".maf", "bin", "vault")
31
- GLOBAL_COMMANDS[harness]&.then { |text| format(text, name: "#{NAME}-#{File.basename(project)}", vault: vault) }
17
+ harness.mcp_command("#{NAME}-#{File.basename(project)}", File.join(project, ".maf", "bin", "vault"))
32
18
  end
33
19
 
34
20
  def initialize(project, harness)
35
- @target = TARGETS.fetch(harness)
21
+ @target = harness.mcp
36
22
  @path = File.join(project, @target[:file])
37
23
  end
38
24
 
@@ -63,7 +49,7 @@ module Maf
63
49
  end
64
50
 
65
51
  def save(data)
66
- data = { "$schema" => SCHEMA }.merge(data) if @target[:key] == "mcp" && !data.key?("$schema")
52
+ data = { "$schema" => @target[:schema] }.merge(data) if @target[:schema] && !data.key?("$schema")
67
53
  FileUtils.mkdir_p(File.dirname(@path))
68
54
  File.write(@path, "#{JSON.pretty_generate(data)}\n")
69
55
  end
@@ -13,13 +13,13 @@ module Maf
13
13
  def install
14
14
  return if @options.check? || disabled?
15
15
 
16
- @options.agents.map { |a| a[:harness] }.uniq.each { |harness| install_for(harness) }
16
+ @options.agents.map { |a| a[:harness] }.uniq.each { |name| install_for(Harness.fetch(name)) }
17
17
  end
18
18
 
19
19
  private
20
20
 
21
21
  def install_for(harness)
22
- if McpConfig.targets.key?(harness)
22
+ if harness.mcp
23
23
  report(harness, McpConfig.new(@project, harness).write)
24
24
  elsif (command = McpConfig.global_command(harness, @project))
25
25
  puts "mcp: the #{harness} harness keeps MCP servers in a global file. Add the server yourself:\n #{command}"
@@ -27,7 +27,7 @@ module Maf
27
27
  end
28
28
 
29
29
  def report(harness, status)
30
- file = McpConfig.targets.fetch(harness).fetch(:file)
30
+ file = harness.mcp.fetch(:file)
31
31
  return puts("mcp: wrote the graphify server to #{file}") if status == :create
32
32
  return unless status == :refuse
33
33