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/assets/dispatcher CHANGED
@@ -11,7 +11,7 @@
11
11
  # dispatcher polls the inbox and the task board on a fixed interval.
12
12
  #
13
13
  # Usage:
14
- # dispatcher ROLE [--harness NAME | --command TEMPLATE] [--model M]
14
+ # dispatcher ROLE (--harness NAME | --command TEMPLATE) [--model M] [--effort L]
15
15
  # [--skill NAME | --no-skill] [--interval S] [--max-turns N]
16
16
  # [--timeout S] [--idle-timeout S] [--grace S]
17
17
  # [--completion-signal TEXT] [--abort-signal TEXT]
@@ -19,20 +19,24 @@
19
19
  # [--no-poll-tasks]
20
20
  # [--full-harness] [--once] [--verbose]
21
21
  #
22
- # dispatcher tester # hermes (default)
22
+ # dispatcher tester --harness hermes
23
23
  # dispatcher reviewer --harness claude --model sonnet
24
24
  # dispatcher backend-developer --harness opencode --model openrouter/qwen3-coder
25
25
  # dispatcher reviewer --harness codex
26
- # dispatcher reviewer --once # one poll cycle, then exit
26
+ # dispatcher reviewer --harness cursor --model gpt-5
27
+ # dispatcher reviewer --harness claude --once # one poll cycle, then exit
27
28
  #
28
29
  # Flags:
29
- # --harness NAME Built-in adapter: hermes (default), claude, codex, or
30
- # opencode. Each one resumes sessions.
30
+ # --harness NAME A built-in harness: one file in .maf/lib/maf/shared/harnesses/
31
+ # (claude, codex, cursor, hermes, opencode). Each one
32
+ # resumes sessions. --harness or --command is required.
31
33
  # --command TEMPLATE Any other harness. A shell command with %{prompt},
32
- # %{role}, %{skill}, and %{model} placeholders, each
34
+ # %{role}, %{skill}, %{model}, and %{effort} placeholders, each
33
35
  # shell-escaped. The command must exit when done.
34
36
  # Sessions are not resumed for custom commands.
35
37
  # --model M Model for the agent, in the harness CLI's own format.
38
+ # --effort L Effort level of the model, in the harness CLI's own
39
+ # format (for example high). Default: the harness default.
36
40
  # --skill NAME Hermes skill to preload. Default: <project>-<role> when
37
41
  # flow.rb generated it.
38
42
  # --no-skill Do not preload a skill.
@@ -53,7 +57,8 @@
53
57
  # --abort-signal TEXT The agent prints TEXT when it gives up. The run is a
54
58
  # failure. Default: off.
55
59
  # --cache-window S Resume a session only if its last run ended less than
56
- # S seconds ago. Default: 300 for codex, else 3300
60
+ # S seconds ago. Default: the cache window of the harness
61
+ # (codex: 300), else 3300
57
62
  # (55 minutes). 0 means never resume.
58
63
  # --max-session-runs N Start a fresh session after N runs in one session
59
64
  # (default: 5). Each resume sends the whole old context
@@ -96,7 +101,9 @@
96
101
  # atomic, so a run never starts for a task that another worker took. If the
97
102
  # same task set is still unclaimed afterwards, it waits with a doubling
98
103
  # backoff (capped at MAX_BACKOFF) before it tries again. A changed task set
99
- # dispatches at once.
104
+ # dispatches at once. After MAX_ATTEMPTS runs in a row that leave a task
105
+ # claimed and not done, the dispatcher blocks the task (`coord block --kind
106
+ # failing`), which escalates it. A usage limit or an unknown model counts no run.
100
107
  #
101
108
  # Sessions and the prompt cache: the session ID is saved to
102
109
  # .maf/coordination/sessions/<worker>.session and resumed on the next run, so the
@@ -136,6 +143,8 @@ begin
136
143
  require_relative "../lib/maf/shared/roles"
137
144
  require_relative "../lib/maf/shared/peak_rate"
138
145
  require_relative "../lib/maf/shared/git_identity"
146
+ require_relative "../lib/maf/shared/harness"
147
+ require_relative "../lib/maf/shared/sandbox"
139
148
  rescue LoadError
140
149
  abort "dispatcher: the maf shared library is missing. Run maf update."
141
150
  end
@@ -146,7 +155,7 @@ module Dispatcher
146
155
  MAX_BACKOFF = 3600
147
156
  REPORT_RETRIES = 1
148
157
 
149
- Config = Struct.new(:role, :harness, :command, :model, :skill, :no_skill, :interval, :max_turns,
158
+ Config = Struct.new(:role, :harness, :command, :model, :effort, :skill, :no_skill, :interval, :max_turns,
150
159
  :timeout, :idle_timeout, :grace, :completion_signal, :abort_signal,
151
160
  :cache_window, :max_session_runs, :max_context, :once, :poll_tasks, :worker, :coord_dir,
152
161
  :verbose, :lean,
@@ -171,29 +180,6 @@ module Dispatcher
171
180
  Project = Maf::Shared::Project
172
181
  LEADS = Maf::Shared::Roles::LEADS
173
182
 
174
- # FlowConfig finds the MCP config of the flow in .maf/mcp/ (next to the
175
- # coordination folder). The project's .mcp.json and opencode.json stay as
176
- # they are. NOTE: lib/maf/setup_agent/launcher.rb passes the same files.
177
- module FlowConfig
178
- def self.path(config, name) = File.join(File.dirname(config.coord_dir), "mcp", name)
179
- def self.claude_mcp(config) = File.exist?(file = path(config, "claude.json")) ? ["--mcp-config", file] : []
180
-
181
- def self.opencode_env(config)
182
- file = path(config, "opencode.json")
183
- File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
184
- end
185
- end
186
-
187
- # EditGrant reads each agent's can_edit from .maf/config.json. On Hermes,
188
- # a role with can_edit false starts without the file-writing toolsets.
189
- module EditGrant
190
- READ_ONLY_TOOLSETS = Maf::Shared::Roles::READ_ONLY_TOOLSETS
191
-
192
- def self.read_only?(role)
193
- Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
194
- end
195
- end
196
-
197
183
  # Presence writes .maf/coordination/presence/<worker>.json once per cycle, so
198
184
  # `coord who` shows the dispatcher as live while its pid runs.
199
185
  module Presence
@@ -210,17 +196,6 @@ module Dispatcher
210
196
  end
211
197
  end
212
198
 
213
- # HermesSkill resolves the skill flow.rb generated for a role: the name is
214
- # <project>-<role>, and the dir is the manifest's hermes_dir (set by
215
- # `flow.rb --hermes-dir`) or flow.rb's default ~/.hermes/skills.
216
- module HermesSkill
217
- DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
218
-
219
- def self.name(role) = "#{File.basename(Project.root)}-#{role}"
220
- def self.dir = Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
221
- def self.installed?(role) = File.exist?(File.join(dir, name(role), "SKILL.md"))
222
- end
223
-
224
199
  # Session persists the agent's session ID across runs, per worker: two
225
200
  # dispatchers for one role (different COORD_WORKER values) never share a
226
201
  # session or a handoff note. The session file's mtime is the time of the
@@ -347,6 +322,49 @@ module Dispatcher
347
322
  end
348
323
  end
349
324
 
325
+ # FailureLimit counts the runs in a row that left a task claimed and not
326
+ # done. At MAX_ATTEMPTS it blocks the task, which escalates it. The count is in
327
+ # .maf/coordination/failures/<id>, so a restart keeps it.
328
+ class FailureLimit
329
+ NOTICE = "Task %<id>s is blocked: %<runs>d runs of %<worker>s in a row ended without coord done. " \
330
+ "Last result: %<last>s Fix the cause, then run `coord unblock %<id>s`."
331
+ UNCOUNTED = %i[limited broken].freeze
332
+
333
+ def initialize(config, poller, coord = ->(*args) { CoordCall.run(Main.run_env(config), *args) })
334
+ @config = config
335
+ @poller = poller
336
+ @coord = coord
337
+ @dir = File.join(config.coord_dir, "failures")
338
+ end
339
+
340
+ def track(ids, outcome)
341
+ return if UNCOUNTED.include?(outcome)
342
+
343
+ open = outcome == :waiting ? [] : @poller.claimed_task_ids
344
+ ids.each { |id| open.include?(id) ? count(id) : clear(id) }
345
+ end
346
+
347
+ def runs(id) = File.exist?(path(id)) ? File.read(path(id)).to_i : 0
348
+
349
+ private
350
+
351
+ def count(id)
352
+ runs = runs(id) + 1
353
+ runs >= MAX_ATTEMPTS ? block(id, runs) : save(id, runs)
354
+ end
355
+
356
+ def block(id, runs)
357
+ text = format(NOTICE, id: id, runs: runs, worker: @config.worker, last: Log.last.to_s[0, 200])
358
+ Log.line("task #{id}: #{runs} unfinished runs in a row; blocking it")
359
+ @coord.call("block", "--kind", "failing", id, text)
360
+ clear(id)
361
+ end
362
+
363
+ def save(id, runs) = FileUtils.mkdir_p(@dir) && File.write(path(id), runs.to_s)
364
+ def clear(id) = FileUtils.rm_f(path(id))
365
+ def path(id) = File.join(@dir, id)
366
+ end
367
+
350
368
  # Lessons reads the dead ends and corrections of graphify-out/reflections/
351
369
  # LESSONS.md for one task, newest first. coord done and coord lesson save the
352
370
  # notes that LESSONS.md sums up. A lesson stays when its note cites a node of
@@ -532,10 +550,7 @@ module Dispatcher
532
550
  # CoordCall runs coord with this Ruby, so an old system Ruby on the
533
551
  # shebang path never parses it.
534
552
  module CoordCall
535
- def self.command
536
- local = File.expand_path(COORD_BIN)
537
- [RbConfig.ruby, File.executable?(local) ? local : File.join(Project.root, COORD_BIN)]
538
- end
553
+ def self.command = [RbConfig.ruby, File.join(Project.root, COORD_BIN)]
539
554
 
540
555
  # Returns true when the command succeeds.
541
556
  def self.run(env, *args)
@@ -676,10 +691,25 @@ module Dispatcher
676
691
 
677
692
  def self.run(cmd, env:, timeout:, limits: Limits.new(0, GRACE))
678
693
  reader, writer = IO.pipe
679
- pid = Process.spawn(env, *cmd, in: File::NULL, out: writer, err: writer, pgroup: true)
694
+ pid = start(env, cmd, writer)
680
695
  collect(pid, Stream.new(reader.tap { writer.close }), Watch.new(timeout, limits))
681
- rescue Errno::ENOENT => e
682
- Result.new(e.message, false, false, :missing)
696
+ end
697
+
698
+ def self.start(env, cmd, writer)
699
+ ready, done = IO.pipe
700
+ pid = fork { session_exec(env, cmd, writer, done) }
701
+ done.close
702
+ ready.read && ready.close
703
+ pid
704
+ end
705
+
706
+ def self.session_exec(env, cmd, writer, done)
707
+ Process.setsid
708
+ done.close
709
+ exec(env, *cmd, in: File::NULL, out: writer, err: writer)
710
+ rescue StandardError => e
711
+ writer.write(e.message)
712
+ exit!(127)
683
713
  end
684
714
 
685
715
  def self.collect(pid, stream, watch)
@@ -704,55 +734,16 @@ module Dispatcher
704
734
  end
705
735
  end
706
736
 
707
- # Events reads the JSON event lines of a harness run. Each adapter takes
708
- # its session ID from one specific event, never from free text, so a
709
- # prompt or a handoff note that the agent echoes cannot supply a false ID.
710
- module Events
711
- def self.all(output) = output.lines.filter_map { |line| parse(line.strip) }
712
-
713
- def self.last(output, &match) = all(output).reverse.find(&match) || {}
737
+ Events = Maf::Shared::Harness::Events
714
738
 
715
- def self.parse(line)
716
- event = line.start_with?("{") ? JSON.parse(line) : nil
717
- event.is_a?(Hash) ? event : nil
718
- rescue JSON::ParserError
719
- nil
720
- end
721
- end
722
-
723
- # TokenUsage reads the token usage of one run and adds it to the totals of
724
- # the worker in .maf/coordination/usage/<worker>.json. Claude Code, Hermes,
725
- # Codex, and opencode report usage. A run without usage data adds nothing. A usage error
726
- # never fails a run.
727
- #
728
- # input_tokens counts every input token, cache reads included.
729
- # cached_input_tokens counts the cache reads, which cost a fraction of the
730
- # input price. cache_write_input_tokens counts the cache writes, which cost
731
- # more than the input price. A resume with a large cache write found a cold
732
- # cache: the --cache-window is too long for the harness. Claude Code reports
733
- # cache reads and cache writes outside its input_tokens, so they are added.
734
- # Codex reports them inside.
739
+ # TokenUsage adds the token usage of one run to the totals of the worker in
740
+ # .maf/coordination/usage/<worker>.json. Each harness reads the usage of its
741
+ # run (Maf::Shared::Harness::Usage gives it one shape). A run without usage
742
+ # data adds nothing. A usage error never fails a run. A resume with a large
743
+ # cache write found a cold cache: the --cache-window is too long for the harness.
735
744
  class TokenUsage
736
- def self.normalize(data)
737
- return nil unless data.is_a?(Hash)
738
-
739
- { "input_tokens" => input(data),
740
- "cached_input_tokens" => int(data, "cached_input_tokens", "cache_read_input_tokens"),
741
- "cache_write_input_tokens" => int(data, "cache_write_input_tokens", "cache_creation_input_tokens"),
742
- "output_tokens" => int(data, "output_tokens", "completion_tokens") }
743
- end
744
-
745
- def self.input(data)
746
- int(data, "input_tokens", "prompt_tokens") + int(data, "cache_read_input_tokens") +
747
- int(data, "cache_creation_input_tokens")
748
- end
749
-
750
- def self.int(data, *names) = data.values_at(*names).compact.first.to_i
751
-
752
745
  def self.label(usage) = usage.map { |key, value| "#{key.delete_suffix("_tokens")}=#{value}" }.join(" ")
753
746
 
754
- def self.sum(list) = list.empty? ? nil : list.reduce { |total, usage| total.merge(usage) { |_, a, b| a + b } }
755
-
756
747
  def initialize(coord_dir, worker)
757
748
  @path = File.join(coord_dir, "usage", "#{worker}.json")
758
749
  end
@@ -802,205 +793,40 @@ module Dispatcher
802
793
  "cache_window" => @config.cache_window }
803
794
  end
804
795
 
805
- # PeakRate tells when a dispatched opencode run uses DeepSeek at the peak
806
- # rate (Maf::Shared::PeakRate). The notice can show on a Chinese holiday.
796
+ # PeakRate tells when a dispatched run uses a model at its peak rate
797
+ # (Maf::Shared::PeakRate). The notice can show on a Chinese holiday.
807
798
  module PeakRate
808
- def self.deepseek?(config) = config.harness == "opencode" && OpencodeModel.for(config).to_s.include?("deepseek")
809
-
810
799
  def self.notice(config, time = Time.now.utc)
811
- return unless Maf::Shared::PeakRate.peak?(time) && deepseek?(config)
800
+ return unless Maf::Shared::PeakRate.peak?(time) && Harness.for(config).peak_rate?(config)
812
801
 
813
802
  "info: DeepSeek peak hours (#{time.strftime("%H:%M")} UTC): this run costs 2x the off-peak rate"
814
803
  end
815
804
  end
816
805
 
817
- # OpencodeModel finds the model of an opencode run in the opencode order:
818
- # --model, the agent file, the project config, the user config.
819
- module OpencodeModel
820
- CONFIG_MODEL = /^\s*"model"\s*:\s*"([^"]+)"/
821
- AGENT_MODEL = /\A---\n(?:(?!^---$).)*?^model:\s*(\S+)/m
822
-
823
- def self.for(config) = config.model || agent(config.role) || configured
824
- def self.agent(role) = find(File.join(".opencode", "agents", "#{role}.md"), AGENT_MODEL)
825
- def self.configured = config_paths.lazy.filter_map { |path| find(path, CONFIG_MODEL) }.first
826
- def self.config_paths = %w[opencode.json opencode.jsonc] + user_paths
827
- def self.user_paths = %w[opencode.jsonc opencode.json].map { |name| File.join(home, name) }
828
- def self.home = File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "opencode")
829
- def self.find(path, pattern) = File.exist?(path) ? File.read(path)[pattern, 1] : nil
830
- end
831
-
806
+ # Harness picks the harness of a run. Each built-in harness is one file in
807
+ # lib/maf/shared/harnesses/ (installed in .maf/lib/maf/shared/harnesses/).
808
+ # Generic runs a user-provided shell template.
832
809
  module Harness
833
- # Adapter is the part every built-in adapter shares. Each adapter sets
834
- # STALE_SESSION to the error its CLI prints for an unknown session ID.
835
- module Adapter
836
- def stale_session?(output) = output.include?(self::STALE_SESSION)
837
- def resume(session_id, *flag) = session_id ? [*flag, session_id] : []
838
- def present(value) = value.to_s.empty? ? nil : value
839
- def usage(_output) = nil
840
- def context(_output) = nil
841
- def model(_output) = nil
842
- end
843
-
844
- # Hermes: `hermes chat --oneshot`, JSONL output, skill preload.
845
- module Hermes
846
- extend Adapter
847
- STALE_SESSION = "Session not found"
848
-
849
- def self.build_command(config, prompt, session_id)
850
- cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q] + options(config)
851
- cmd += ["--max-turns", config.max_turns.to_s, "--run-budget", run_budget(config).to_s]
852
- cmd + resume(session_id, "--resume") + ["-q", prompt]
853
- end
854
-
855
- def self.options(config)
856
- tools = EditGrant.read_only?(config.role) ? ["-t", EditGrant::READ_ONLY_TOOLSETS] : []
857
- [*(["--skills", config.skill] if config.skill), *(["--model", config.model] if config.model), *tools]
858
- end
859
-
860
- # Stop Hermes before the dispatcher's kill, so it can still write its result event.
861
- def self.run_budget(config) = [config.timeout - Spawn::GRACE, 1].max
862
- def self.result_event(output) = Events.last(output) { |event| event["type"] == "result" }
863
- def self.result_text(output) = result_event(output)["text"]
864
- def self.session_id(output) = present(result_event(output)["session_id"])
865
- def self.usage(output) = TokenUsage.normalize(result_event(output)["usage"])
866
- end
867
-
868
- # Claude Code: `claude -p` prints one JSON object with session_id and
869
- # result. `--agent` loads .claude/agents/<role>.md as the session's role.
870
- # `--mcp-config` takes many values. Put `--` before the prompt, or claude
871
- # reads the prompt as one more config file and exits.
872
- module Claude
873
- extend Adapter
874
- STALE_SESSION = "No conversation found with session ID"
875
-
876
- # LEAN starts no MCP server outside --mcp-config and lists no skill.
877
- # The user's CLAUDE.md and hooks stay.
878
- LEAN = %w[--strict-mcp-config --disable-slash-commands].freeze
879
-
880
- def self.build_command(config, prompt, session_id)
881
- cmd = %w[claude -p --output-format json --permission-mode bypassPermissions --agent] + [config.role]
882
- cmd += ["--model", config.model] if config.model
883
- cmd += LEAN if config.lean
884
- cmd + FlowConfig.claude_mcp(config) + resume(session_id, "--resume") + ["--", prompt]
885
- end
886
-
887
- def self.result_event(output) = Events.last(output) { |event| event.key?("result") }
888
- def self.result_text(output) = result_event(output)["result"]
889
- def self.session_id(output) = present(result_event(output)["session_id"])
890
- def self.usage(output) = TokenUsage.normalize(result_event(output)["usage"])
891
- # The result names each model that the run used. The main model comes first.
892
- def self.model(output) = result_event(output)["modelUsage"]&.keys&.first
893
- end
894
-
895
- # Codex: `codex exec` (or `codex exec resume ID`) with JSONL events. Codex
896
- # has no agent flag, so a fresh session gets .codex/prompts/<role>.md
897
- # in front of the prompt. A resumed session already has it.
898
- module Codex
899
- extend Adapter
900
- STALE_SESSION = "no rollout found for thread id"
901
-
902
- def self.build_command(config, prompt, session_id)
903
- cmd = %w[codex exec] + (session_id ? ["resume"] : [])
904
- cmd += %w[--json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox]
905
- cmd += ["-m", config.model] if config.model
906
- cmd += Lean.flags if config.lean
907
- cmd + resume(session_id) + [session_id ? prompt : role_prompt(config.role) + prompt]
908
- end
909
-
910
- # Lean drops the parts of the user's Codex setup that a one-shot run
911
- # does not use. Each one adds tool schemas, skill lists, or MCP calls
912
- # to every model call. Only the graphify server of this project stays.
913
- module Lean
914
- FEATURES = %w[plugins apps browser_use computer_use multi_agent].freeze
915
- SERVER = /^\[mcp_servers\.([\w-]+)\]\s*$/
916
-
917
- def self.flags = FEATURES.flat_map { |name| ["--disable", name] } + servers.flat_map { |name| off(name) }
918
- def self.off(name) = ["-c", "mcp_servers.#{name}.enabled=false"]
919
- def self.servers = File.exist?(config_path) ? File.read(config_path).scan(SERVER).flatten.uniq - [keep] : []
920
- def self.keep = "graphify-#{File.basename(Project.root)}"
921
- def self.config_path = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
922
- end
923
-
924
- def self.role_prompt(role)
925
- path = File.join(".codex", "prompts", "#{role}.md")
926
- File.exist?(path) ? "#{File.read(path)}\n\n" : ""
927
- end
928
-
929
- def self.result_text(output)
930
- Events.last(output) { |event| event.dig("item", "type") == "agent_message" }.dig("item", "text")
931
- end
932
-
933
- def self.session_id(output)
934
- present(Events.last(output) { |event| event["type"] == "thread.started" }["thread_id"])
935
- end
936
-
937
- # Codex reports the usage of each turn in a turn.completed event.
938
- def self.usage(output)
939
- turns = Events.all(output).select { |event| event["type"] == "turn.completed" }
940
- TokenUsage.sum(turns.filter_map { |event| TokenUsage.normalize(event["usage"]) })
941
- end
942
- end
943
-
944
- # opencode: `opencode run --format json`. `--agent` loads
945
- # .opencode/agents/<role>.md. The result event shape is not verified, so
946
- # the log shows no result text. opencode prints terminal escape codes and
947
- # can glue two JSON objects onto one line, so the session ID is matched on
948
- # the start of an event object (captured from a real run), not by parsing
949
- # whole lines.
950
- module Opencode
951
- extend Adapter
952
- STALE_SESSION = "Session not found"
953
- EVENT_SESSION = /\{"type":"[\w.-]+","timestamp":\d+,"sessionID":"([^"]+)"/
954
-
955
- def self.session_id(output) = output.scan(EVENT_SESSION).flatten.last
956
-
957
- def self.build_command(config, prompt, session_id)
958
- cmd = %w[opencode run --format json --agent] + [config.role]
959
- cmd += ["-m", config.model] if config.model
960
- cmd + resume(session_id, "--session") + [prompt]
961
- end
962
-
963
- def self.result_text(_output) = nil
964
-
965
- # Each model call ends with a step_finish event. Its tokens.input
966
- # excludes the cache reads and writes; reasoning is billed as output.
967
- STEP_FINISH = /\{"type":"step_finish".*/
968
-
969
- def self.steps(output) = output.scan(STEP_FINISH).filter_map { |json| Events.parse(json)&.dig("part", "tokens") }
970
- def self.usage(output) = TokenUsage.sum(steps(output).map { |tokens| TokenUsage.normalize(flat(tokens)) })
971
- def self.context(output) = steps(output).last&.then { |tokens| TokenUsage.input(flat(tokens)) }
972
-
973
- def self.flat(tokens)
974
- { "input_tokens" => tokens["input"].to_i, "cache_read_input_tokens" => tokens.dig("cache", "read").to_i,
975
- "cache_creation_input_tokens" => tokens.dig("cache", "write").to_i,
976
- "output_tokens" => tokens["output"].to_i + tokens["reasoning"].to_i }
977
- end
978
- end
979
-
980
810
  # Generic: a user-provided shell template. No session resume, no parsing.
981
811
  module Generic
982
- PLACEHOLDERS = %i[prompt role skill model].freeze
812
+ extend Maf::Shared::Harness::Run
813
+
814
+ PLACEHOLDERS = %i[prompt role skill model effort].freeze
983
815
 
984
816
  def self.build_command(config, prompt, _session_id)
985
- values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model }
817
+ values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model,
818
+ effort: config.effort }
986
819
  line = PLACEHOLDERS.reduce(config.command) { |cmd, key| cmd.gsub("%{#{key}}") { values[key].to_s.shellescape } }
987
820
  ["sh", "-c", line]
988
821
  end
989
-
990
- def self.stale_session?(_output) = false
991
- def self.session_id(_output) = nil
992
- def self.result_text(_output) = nil
993
- def self.usage(_output) = nil
994
- def self.context(_output) = nil
995
- def self.model(_output) = nil
996
822
  end
997
823
 
998
- REGISTRY = { "hermes" => Hermes, "claude" => Claude, "codex" => Codex, "opencode" => Opencode }.freeze
999
-
1000
824
  def self.for(config)
1001
825
  return Generic if config.command
1002
826
 
1003
- REGISTRY.fetch(config.harness) { abort "dispatcher: unknown harness '#{config.harness}'. Use --command." }
827
+ Maf::Shared::Harness[config.harness] ||
828
+ abort("dispatcher: unknown harness '#{config.harness}' (use #{Maf::Shared::Harness.names.join(", ")}). " \
829
+ "For another harness, use --command.")
1004
830
  end
1005
831
  end
1006
832
 
@@ -1060,7 +886,13 @@ module Dispatcher
1060
886
  command = Project.manifest["verify"].to_s
1061
887
  return true if command.empty? || LEADS.include?(config.role)
1062
888
 
1063
- check(Spawn.run(["sh", "-c", command], env: env, timeout: config.timeout), command)
889
+ sandbox = Maf::Shared::Sandbox
890
+ run = -> { Spawn.run(sandboxed(command, config), env: env, timeout: config.timeout) }
891
+ check(sandbox.keeping_links(Harness::Generic, coord_dir: config.coord_dir, &run), command)
892
+ end
893
+
894
+ def self.sandboxed(command, config)
895
+ Maf::Shared::Sandbox.wrap(["sh", "-c", command], Harness::Generic, coord_dir: config.coord_dir)
1064
896
  end
1065
897
 
1066
898
  def self.check(result, command)
@@ -1122,7 +954,7 @@ module Dispatcher
1122
954
  def started(session_id, runs)
1123
955
  save("mode" => "dispatch", "role" => @config.role, "harness" => @config.command ? "command" : @config.harness,
1124
956
  "pid" => Process.pid, "running" => true, "run_started_at" => now, "session_id" => session_id,
1125
- "session_runs" => runs, "model" => @config.model || read["model"])
957
+ "session_runs" => runs, "model" => @config.model || read["model"], "effort" => @config.effort)
1126
958
  end
1127
959
 
1128
960
  def finished(outcome, detail, model)
@@ -1256,10 +1088,14 @@ module Dispatcher
1256
1088
  spawn(@harness.build_command(@config, text, session_id))
1257
1089
  end
1258
1090
 
1091
+ def sandboxed(cmd) = Maf::Shared::Sandbox.wrap(cmd, @harness, coord_dir: @config.coord_dir)
1092
+ def keeping_links(&block) = Maf::Shared::Sandbox.keeping_links(@harness, coord_dir: @config.coord_dir, &block)
1093
+
1259
1094
  def session_label(id) = id ? "#{id}, idle #{(@session.idle_seconds / 60).round}m" : "new"
1260
1095
 
1261
1096
  def spawn(cmd)
1262
- result = Spawn.run(cmd, env: @env, timeout: @config.timeout, limits: Limits.from(@config))
1097
+ limits = Limits.from(@config)
1098
+ result = keeping_links { Spawn.run(sandboxed(cmd), env: @env, timeout: @config.timeout, limits: limits) }
1263
1099
  record(@harness.usage(result.output), result.output)
1264
1100
  @model = @harness.model(result.output) || @model
1265
1101
  result
@@ -1458,7 +1294,7 @@ module Dispatcher
1458
1294
  env = { "COORD_DIR" => config.coord_dir, "COORD_ROLE" => config.role, "COORD_WORKER" => config.worker,
1459
1295
  "COORD_DISPATCHED" => "1", "PWD" => Dir.pwd }
1460
1296
  taskrc = File.join(config.coord_dir, "taskrc")
1461
- env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {}, FlowConfig.opencode_env(config))
1297
+ env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {}, Harness.for(config).run_env(config))
1462
1298
  end
1463
1299
 
1464
1300
  def run
@@ -1521,9 +1357,11 @@ module Dispatcher
1521
1357
  Log.line("usage limit: no new run until #{@pause.until.utc}")
1522
1358
  end
1523
1359
  def chores = @chores ||= LeadChores.new(@poller, @config.role)
1360
+ def failure_limit = @failure_limit ||= FailureLimit.new(@config, @poller)
1524
1361
 
1525
1362
  def start
1526
1363
  Log.line("started: role=#{@config.role} #{harness_label} #{timing_label}")
1364
+ Log.line("sandbox: #{Maf::Shared::Sandbox.describe(Harness.for(@config), coord_dir: @config.coord_dir)}")
1527
1365
  @mailbox.recover
1528
1366
  @stop = StopSignal.new
1529
1367
  end
@@ -1548,11 +1386,8 @@ module Dispatcher
1548
1386
  # unknown model returns them without an attempt.
1549
1387
  SETTLE = { failed: :release, limited: :restore, broken: :restore }.freeze
1550
1388
 
1551
- # Tasks still unclaimed after a message run count as dispatched, so the
1552
- # next cycle backs off instead of starting another run for them.
1553
1389
  def settle(messages, outcome)
1554
1390
  @mailbox.public_send(SETTLE.fetch(outcome, :ack), messages)
1555
- @watch.dispatched(@poller.unclaimed_task_ids) if @config.poll_tasks
1556
1391
  true
1557
1392
  end
1558
1393
 
@@ -1561,7 +1396,7 @@ module Dispatcher
1561
1396
  ids = @poller.claimed_task_ids
1562
1397
  return false unless @resume.due?(ids)
1563
1398
 
1564
- dispatch(Prompt.resume(@config.role, ids) + context(ids.first))
1399
+ failure_limit.track(ids, dispatch(Prompt.resume(@config.role, ids) + context(ids.first)))
1565
1400
  @resume.dispatched(ids)
1566
1401
  true
1567
1402
  end
@@ -1577,7 +1412,7 @@ module Dispatcher
1577
1412
  end
1578
1413
 
1579
1414
  def run_task(id)
1580
- dispatch(Prompt.task(@config.role, id) + context(id))
1415
+ failure_limit.track([id], dispatch(Prompt.task(@config.role, id) + context(id)))
1581
1416
  true
1582
1417
  end
1583
1418
  end
@@ -1585,7 +1420,7 @@ module Dispatcher
1585
1420
  # Options parses the command line into a Config.
1586
1421
  module Options
1587
1422
  VALUES = { "--harness NAME" => [:harness, String], "--command TEMPLATE" => [:command, String],
1588
- "--model M" => [:model, String], "--skill NAME" => [:skill, String],
1423
+ "--model M" => [:model, String], "--effort L" => [:effort, String], "--skill NAME" => [:skill, String],
1589
1424
  "--interval S" => [:interval, Integer], "--max-turns N" => [:max_turns, Integer],
1590
1425
  "--timeout S" => [:timeout, Integer], "--cache-window S" => [:cache_window, Integer],
1591
1426
  "--max-session-runs N" => [:max_session_runs, Integer], "--max-context N" => [:max_context, Integer],
@@ -1595,7 +1430,7 @@ module Dispatcher
1595
1430
  SWITCHES = { "--no-skill" => [:no_skill, true], "--no-poll-tasks" => [:poll_tasks, false],
1596
1431
  "--full-harness" => [:lean, false],
1597
1432
  "--once" => [:once, true], "--verbose" => [:verbose, true] }.freeze
1598
- DEFAULTS = { harness: "hermes", interval: 60, max_turns: 50, idle_timeout: 0, grace: Spawn::GRACE,
1433
+ DEFAULTS = { interval: 60, idle_timeout: 0, grace: Spawn::GRACE,
1599
1434
  poll_tasks: true, lean: true,
1600
1435
  no_skill: false, once: false, verbose: false }.freeze
1601
1436
 
@@ -1603,16 +1438,15 @@ module Dispatcher
1603
1438
  DEFAULT_SESSION_RUNS = 5
1604
1439
  DEFAULT_CONTEXT = 150_000
1605
1440
 
1606
- # Claude Code caches for 1 hour. The OpenAI cache of Codex is best effort:
1607
- # a probe missed it after 5 idle minutes. A cold resume costs more than a
1608
- # fresh session with the handoff note.
1609
- CACHE_WINDOWS = { "codex" => 300 }.freeze
1441
+ # Claude Code caches for 1 hour. A harness with a shorter cache sets its
1442
+ # cache_window. A cold resume costs more than a fresh session with the handoff note.
1610
1443
  DEFAULT_CACHE_WINDOW = 3300
1611
1444
 
1612
1445
  def self.parse(argv, env = ENV)
1613
1446
  config = Config.new(**DEFAULTS)
1614
1447
  parser(config).parse!(argv)
1615
1448
  config.role = argv.shift || abort("dispatcher: role is required (usage: dispatcher ROLE)")
1449
+ abort "dispatcher: --harness NAME or --command TEMPLATE is required" unless config.harness || config.command
1616
1450
  validate(finish(config, env))
1617
1451
  end
1618
1452
 
@@ -1650,7 +1484,7 @@ module Dispatcher
1650
1484
  config.coord_dir = File.expand_path(env.fetch("COORD_DIR", ".maf/coordination"))
1651
1485
  config.worker = env.fetch("COORD_WORKER", "#{config.role}-bot")
1652
1486
  config.poll_tasks = false if LEADS.include?(config.role)
1653
- skill(limits(config))
1487
+ Harness.for(config).prepare(limits(config))
1654
1488
  end
1655
1489
 
1656
1490
  def self.limits(config)
@@ -1662,19 +1496,14 @@ module Dispatcher
1662
1496
  # "team": { "limits": { "frontend-developer": { "max_context": 80000 } } }.
1663
1497
  # `maf worker restart ID --max-context N` writes them. A command-line flag wins.
1664
1498
  # NOTE: assets/dashboard reads the limits of each run from the run history.
1499
+ def self.cache_window(config) = Harness.for(config).cache_window || DEFAULT_CACHE_WINDOW
1500
+
1665
1501
  def self.session_limits(config, saved)
1666
- config.cache_window ||= saved["cache_window"] || CACHE_WINDOWS.fetch(config.harness, DEFAULT_CACHE_WINDOW)
1502
+ config.cache_window ||= saved["cache_window"] || cache_window(config)
1667
1503
  config.max_session_runs ||= saved["max_session_runs"] || DEFAULT_SESSION_RUNS
1668
1504
  config.max_context ||= saved["max_context"] || DEFAULT_CONTEXT
1669
1505
  config
1670
1506
  end
1671
-
1672
- # --no-skill drops a skill. Without --skill, the generated Hermes skill of the role loads.
1673
- def self.skill(config)
1674
- config.skill = nil if config.no_skill
1675
- config.skill ||= HermesSkill.name(config.role) if !config.no_skill && HermesSkill.installed?(config.role)
1676
- config
1677
- end
1678
1507
  end
1679
1508
  end
1680
1509