maf 0.1.0 → 0.2.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +11 -10
  4. data/assets/analyst +29 -51
  5. data/assets/coord +23 -20
  6. data/assets/dashboard +1 -5
  7. data/assets/dispatcher +69 -286
  8. data/assets/harness-hooks/context-watch.rb +5 -4
  9. data/assets/lima/maf.yaml.erb +61 -0
  10. data/lib/maf/bootstrap/script_planner.rb +1 -1
  11. data/lib/maf/cli.rb +3 -0
  12. data/lib/maf/flow/agent_links.rb +2 -2
  13. data/lib/maf/flow/bootstrapper.rb +5 -5
  14. data/lib/maf/flow/codex_hooks.rb +12 -1
  15. data/lib/maf/flow/generator.rb +1 -1
  16. data/lib/maf/flow/harness_linker.rb +2 -2
  17. data/lib/maf/flow/hook_files.rb +6 -0
  18. data/lib/maf/flow/hook_installer.rb +6 -20
  19. data/lib/maf/flow/manifest.rb +2 -2
  20. data/lib/maf/flow/mcp_config.rb +8 -22
  21. data/lib/maf/flow/mcp_installer.rb +3 -3
  22. data/lib/maf/flow/models.rb +6 -41
  23. data/lib/maf/flow/options.rb +4 -2
  24. data/lib/maf/flow/prompt_builder.rb +7 -14
  25. data/lib/maf/flow/prompt_text.rb +7 -11
  26. data/lib/maf/flow/report.rb +2 -4
  27. data/lib/maf/flow/role_files.rb +4 -19
  28. data/lib/maf/flow/validator.rb +2 -2
  29. data/lib/maf/flow.rb +4 -20
  30. data/lib/maf/menu.rb +1 -1
  31. data/lib/maf/migrate/role_files.rb +3 -1
  32. data/lib/maf/retire.rb +42 -6
  33. data/lib/maf/setup_agent/dispatch.rb +1 -1
  34. data/lib/maf/setup_agent/launcher.rb +15 -66
  35. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  36. data/lib/maf/setup_agent/role_file.rb +1 -1
  37. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  38. data/lib/maf/setup_agent.rb +1 -3
  39. data/lib/maf/shared/harness/base.rb +57 -0
  40. data/lib/maf/shared/harness/defaults.rb +140 -0
  41. data/lib/maf/shared/harness/output.rb +55 -0
  42. data/lib/maf/shared/harness/traits.rb +35 -0
  43. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  44. data/lib/maf/shared/harness.rb +12 -0
  45. data/lib/maf/shared/harnesses/claude.rb +134 -0
  46. data/lib/maf/shared/harnesses/codex.rb +173 -0
  47. data/lib/maf/shared/harnesses/cursor.rb +64 -0
  48. data/lib/maf/shared/harnesses/hermes.rb +112 -0
  49. data/lib/maf/shared/harnesses/opencode.rb +169 -0
  50. data/lib/maf/shared/processes.rb +20 -0
  51. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  52. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  53. data/lib/maf/shared/sandbox/links.rb +28 -0
  54. data/lib/maf/shared/sandbox/paths.rb +124 -0
  55. data/lib/maf/shared/sandbox/references.rb +64 -0
  56. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  57. data/lib/maf/shared/sandbox.rb +84 -0
  58. data/lib/maf/team_command.rb +3 -3
  59. data/lib/maf/uninstall/manifest.rb +10 -8
  60. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  61. data/lib/maf/uninstall/notes.rb +13 -2
  62. data/lib/maf/uninstall/role_files.rb +11 -10
  63. data/lib/maf/uninstall/runner.rb +4 -3
  64. data/lib/maf/uninstall/scripts.rb +1 -1
  65. data/lib/maf/uninstall/vm_files.rb +16 -0
  66. data/lib/maf/uninstall.rb +20 -5
  67. data/lib/maf/untrack.rb +6 -26
  68. data/lib/maf/version.rb +1 -1
  69. data/lib/maf/vm/login.rb +52 -0
  70. data/lib/maf/vm/options.rb +41 -0
  71. data/lib/maf/vm/template.rb +44 -0
  72. data/lib/maf/vm.rb +94 -0
  73. data/lib/maf/worker_control.rb +4 -7
  74. data/lib/maf/workers.rb +27 -0
  75. metadata +30 -11
  76. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  77. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  78. data/templates/claude.md.erb +0 -16
  79. data/templates/codex.md.erb +0 -7
  80. data/templates/hermes.md.erb +0 -12
  81. 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]
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,15 +19,17 @@
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
34
  # %{role}, %{skill}, and %{model} placeholders, each
33
35
  # shell-escaped. The command must exit when done.
@@ -53,7 +55,8 @@
53
55
  # --abort-signal TEXT The agent prints TEXT when it gives up. The run is a
54
56
  # failure. Default: off.
55
57
  # --cache-window S Resume a session only if its last run ended less than
56
- # S seconds ago. Default: 300 for codex, else 3300
58
+ # S seconds ago. Default: the cache window of the harness
59
+ # (codex: 300), else 3300
57
60
  # (55 minutes). 0 means never resume.
58
61
  # --max-session-runs N Start a fresh session after N runs in one session
59
62
  # (default: 5). Each resume sends the whole old context
@@ -136,6 +139,8 @@ begin
136
139
  require_relative "../lib/maf/shared/roles"
137
140
  require_relative "../lib/maf/shared/peak_rate"
138
141
  require_relative "../lib/maf/shared/git_identity"
142
+ require_relative "../lib/maf/shared/harness"
143
+ require_relative "../lib/maf/shared/sandbox"
139
144
  rescue LoadError
140
145
  abort "dispatcher: the maf shared library is missing. Run maf update."
141
146
  end
@@ -171,29 +176,6 @@ module Dispatcher
171
176
  Project = Maf::Shared::Project
172
177
  LEADS = Maf::Shared::Roles::LEADS
173
178
 
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
179
  # Presence writes .maf/coordination/presence/<worker>.json once per cycle, so
198
180
  # `coord who` shows the dispatcher as live while its pid runs.
199
181
  module Presence
@@ -210,17 +192,6 @@ module Dispatcher
210
192
  end
211
193
  end
212
194
 
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
195
  # Session persists the agent's session ID across runs, per worker: two
225
196
  # dispatchers for one role (different COORD_WORKER values) never share a
226
197
  # session or a handoff note. The session file's mtime is the time of the
@@ -532,10 +503,7 @@ module Dispatcher
532
503
  # CoordCall runs coord with this Ruby, so an old system Ruby on the
533
504
  # shebang path never parses it.
534
505
  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
506
+ def self.command = [RbConfig.ruby, File.join(Project.root, COORD_BIN)]
539
507
 
540
508
  # Returns true when the command succeeds.
541
509
  def self.run(env, *args)
@@ -676,10 +644,25 @@ module Dispatcher
676
644
 
677
645
  def self.run(cmd, env:, timeout:, limits: Limits.new(0, GRACE))
678
646
  reader, writer = IO.pipe
679
- pid = Process.spawn(env, *cmd, in: File::NULL, out: writer, err: writer, pgroup: true)
647
+ pid = start(env, cmd, writer)
680
648
  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)
649
+ end
650
+
651
+ def self.start(env, cmd, writer)
652
+ ready, done = IO.pipe
653
+ pid = fork { session_exec(env, cmd, writer, done) }
654
+ done.close
655
+ ready.read && ready.close
656
+ pid
657
+ end
658
+
659
+ def self.session_exec(env, cmd, writer, done)
660
+ Process.setsid
661
+ done.close
662
+ exec(env, *cmd, in: File::NULL, out: writer, err: writer)
663
+ rescue StandardError => e
664
+ writer.write(e.message)
665
+ exit!(127)
683
666
  end
684
667
 
685
668
  def self.collect(pid, stream, watch)
@@ -704,55 +687,16 @@ module Dispatcher
704
687
  end
705
688
  end
706
689
 
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) || {}
714
-
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
690
+ Events = Maf::Shared::Harness::Events
722
691
 
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.
692
+ # TokenUsage adds the token usage of one run to the totals of the worker in
693
+ # .maf/coordination/usage/<worker>.json. Each harness reads the usage of its
694
+ # run (Maf::Shared::Harness::Usage gives it one shape). A run without usage
695
+ # data adds nothing. A usage error never fails a run. A resume with a large
696
+ # cache write found a cold cache: the --cache-window is too long for the harness.
735
697
  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
698
  def self.label(usage) = usage.map { |key, value| "#{key.delete_suffix("_tokens")}=#{value}" }.join(" ")
753
699
 
754
- def self.sum(list) = list.empty? ? nil : list.reduce { |total, usage| total.merge(usage) { |_, a, b| a + b } }
755
-
756
700
  def initialize(coord_dir, worker)
757
701
  @path = File.join(coord_dir, "usage", "#{worker}.json")
758
702
  end
@@ -802,183 +746,24 @@ module Dispatcher
802
746
  "cache_window" => @config.cache_window }
803
747
  end
804
748
 
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.
749
+ # PeakRate tells when a dispatched run uses a model at its peak rate
750
+ # (Maf::Shared::PeakRate). The notice can show on a Chinese holiday.
807
751
  module PeakRate
808
- def self.deepseek?(config) = config.harness == "opencode" && OpencodeModel.for(config).to_s.include?("deepseek")
809
-
810
752
  def self.notice(config, time = Time.now.utc)
811
- return unless Maf::Shared::PeakRate.peak?(time) && deepseek?(config)
753
+ return unless Maf::Shared::PeakRate.peak?(time) && Harness.for(config).peak_rate?(config)
812
754
 
813
755
  "info: DeepSeek peak hours (#{time.strftime("%H:%M")} UTC): this run costs 2x the off-peak rate"
814
756
  end
815
757
  end
816
758
 
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
-
759
+ # Harness picks the harness of a run. Each built-in harness is one file in
760
+ # lib/maf/shared/harnesses/ (installed in .maf/lib/maf/shared/harnesses/).
761
+ # Generic runs a user-provided shell template.
832
762
  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
763
  # Generic: a user-provided shell template. No session resume, no parsing.
981
764
  module Generic
765
+ extend Maf::Shared::Harness::Run
766
+
982
767
  PLACEHOLDERS = %i[prompt role skill model].freeze
983
768
 
984
769
  def self.build_command(config, prompt, _session_id)
@@ -986,21 +771,14 @@ module Dispatcher
986
771
  line = PLACEHOLDERS.reduce(config.command) { |cmd, key| cmd.gsub("%{#{key}}") { values[key].to_s.shellescape } }
987
772
  ["sh", "-c", line]
988
773
  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
774
  end
997
775
 
998
- REGISTRY = { "hermes" => Hermes, "claude" => Claude, "codex" => Codex, "opencode" => Opencode }.freeze
999
-
1000
776
  def self.for(config)
1001
777
  return Generic if config.command
1002
778
 
1003
- REGISTRY.fetch(config.harness) { abort "dispatcher: unknown harness '#{config.harness}'. Use --command." }
779
+ Maf::Shared::Harness[config.harness] ||
780
+ abort("dispatcher: unknown harness '#{config.harness}' (use #{Maf::Shared::Harness.names.join(", ")}). " \
781
+ "For another harness, use --command.")
1004
782
  end
1005
783
  end
1006
784
 
@@ -1060,7 +838,13 @@ module Dispatcher
1060
838
  command = Project.manifest["verify"].to_s
1061
839
  return true if command.empty? || LEADS.include?(config.role)
1062
840
 
1063
- check(Spawn.run(["sh", "-c", command], env: env, timeout: config.timeout), command)
841
+ sandbox = Maf::Shared::Sandbox
842
+ run = -> { Spawn.run(sandboxed(command, config), env: env, timeout: config.timeout) }
843
+ check(sandbox.keeping_links(Harness::Generic, coord_dir: config.coord_dir, &run), command)
844
+ end
845
+
846
+ def self.sandboxed(command, config)
847
+ Maf::Shared::Sandbox.wrap(["sh", "-c", command], Harness::Generic, coord_dir: config.coord_dir)
1064
848
  end
1065
849
 
1066
850
  def self.check(result, command)
@@ -1256,10 +1040,14 @@ module Dispatcher
1256
1040
  spawn(@harness.build_command(@config, text, session_id))
1257
1041
  end
1258
1042
 
1043
+ def sandboxed(cmd) = Maf::Shared::Sandbox.wrap(cmd, @harness, coord_dir: @config.coord_dir)
1044
+ def keeping_links(&block) = Maf::Shared::Sandbox.keeping_links(@harness, coord_dir: @config.coord_dir, &block)
1045
+
1259
1046
  def session_label(id) = id ? "#{id}, idle #{(@session.idle_seconds / 60).round}m" : "new"
1260
1047
 
1261
1048
  def spawn(cmd)
1262
- result = Spawn.run(cmd, env: @env, timeout: @config.timeout, limits: Limits.from(@config))
1049
+ limits = Limits.from(@config)
1050
+ result = keeping_links { Spawn.run(sandboxed(cmd), env: @env, timeout: @config.timeout, limits: limits) }
1263
1051
  record(@harness.usage(result.output), result.output)
1264
1052
  @model = @harness.model(result.output) || @model
1265
1053
  result
@@ -1458,7 +1246,7 @@ module Dispatcher
1458
1246
  env = { "COORD_DIR" => config.coord_dir, "COORD_ROLE" => config.role, "COORD_WORKER" => config.worker,
1459
1247
  "COORD_DISPATCHED" => "1", "PWD" => Dir.pwd }
1460
1248
  taskrc = File.join(config.coord_dir, "taskrc")
1461
- env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {}, FlowConfig.opencode_env(config))
1249
+ env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {}, Harness.for(config).run_env(config))
1462
1250
  end
1463
1251
 
1464
1252
  def run
@@ -1524,6 +1312,7 @@ module Dispatcher
1524
1312
 
1525
1313
  def start
1526
1314
  Log.line("started: role=#{@config.role} #{harness_label} #{timing_label}")
1315
+ Log.line("sandbox: #{Maf::Shared::Sandbox.describe(Harness.for(@config), coord_dir: @config.coord_dir)}")
1527
1316
  @mailbox.recover
1528
1317
  @stop = StopSignal.new
1529
1318
  end
@@ -1595,7 +1384,7 @@ module Dispatcher
1595
1384
  SWITCHES = { "--no-skill" => [:no_skill, true], "--no-poll-tasks" => [:poll_tasks, false],
1596
1385
  "--full-harness" => [:lean, false],
1597
1386
  "--once" => [:once, true], "--verbose" => [:verbose, true] }.freeze
1598
- DEFAULTS = { harness: "hermes", interval: 60, max_turns: 50, idle_timeout: 0, grace: Spawn::GRACE,
1387
+ DEFAULTS = { interval: 60, idle_timeout: 0, grace: Spawn::GRACE,
1599
1388
  poll_tasks: true, lean: true,
1600
1389
  no_skill: false, once: false, verbose: false }.freeze
1601
1390
 
@@ -1603,16 +1392,15 @@ module Dispatcher
1603
1392
  DEFAULT_SESSION_RUNS = 5
1604
1393
  DEFAULT_CONTEXT = 150_000
1605
1394
 
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
1395
+ # Claude Code caches for 1 hour. A harness with a shorter cache sets its
1396
+ # cache_window. A cold resume costs more than a fresh session with the handoff note.
1610
1397
  DEFAULT_CACHE_WINDOW = 3300
1611
1398
 
1612
1399
  def self.parse(argv, env = ENV)
1613
1400
  config = Config.new(**DEFAULTS)
1614
1401
  parser(config).parse!(argv)
1615
1402
  config.role = argv.shift || abort("dispatcher: role is required (usage: dispatcher ROLE)")
1403
+ abort "dispatcher: --harness NAME or --command TEMPLATE is required" unless config.harness || config.command
1616
1404
  validate(finish(config, env))
1617
1405
  end
1618
1406
 
@@ -1650,7 +1438,7 @@ module Dispatcher
1650
1438
  config.coord_dir = File.expand_path(env.fetch("COORD_DIR", ".maf/coordination"))
1651
1439
  config.worker = env.fetch("COORD_WORKER", "#{config.role}-bot")
1652
1440
  config.poll_tasks = false if LEADS.include?(config.role)
1653
- skill(limits(config))
1441
+ Harness.for(config).prepare(limits(config))
1654
1442
  end
1655
1443
 
1656
1444
  def self.limits(config)
@@ -1662,19 +1450,14 @@ module Dispatcher
1662
1450
  # "team": { "limits": { "frontend-developer": { "max_context": 80000 } } }.
1663
1451
  # `maf worker restart ID --max-context N` writes them. A command-line flag wins.
1664
1452
  # NOTE: assets/dashboard reads the limits of each run from the run history.
1453
+ def self.cache_window(config) = Harness.for(config).cache_window || DEFAULT_CACHE_WINDOW
1454
+
1665
1455
  def self.session_limits(config, saved)
1666
- config.cache_window ||= saved["cache_window"] || CACHE_WINDOWS.fetch(config.harness, DEFAULT_CACHE_WINDOW)
1456
+ config.cache_window ||= saved["cache_window"] || cache_window(config)
1667
1457
  config.max_session_runs ||= saved["max_session_runs"] || DEFAULT_SESSION_RUNS
1668
1458
  config.max_context ||= saved["max_context"] || DEFAULT_CONTEXT
1669
1459
  config
1670
1460
  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
1461
  end
1679
1462
  end
1680
1463
 
@@ -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.
@@ -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/cli.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "team"
13
13
  require_relative "team_command"
14
14
  require_relative "worker_control"
15
15
  require_relative "untrack"
16
+ require_relative "vm"
16
17
  require_relative "version"
17
18
 
18
19
  module Maf
@@ -54,6 +55,7 @@ module Maf
54
55
  "uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
55
56
  "migrate" => "[--check] [--yes] move an old-layout install into .maf/",
56
57
  "untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
58
+ "vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
57
59
  "menu" => " interactive mode (also: maf without a command)",
58
60
  "guide" => " print the setup guide for an AI agent (install.md)",
59
61
  "version" => " print the maf version (also: --version)"
@@ -117,6 +119,7 @@ module Maf
117
119
  def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
118
120
  def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
119
121
  def run_menu = Menu.new.run
122
+ def run_vm = Vm.new(@args, SetupAgent::Project.root).run
120
123
  def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
121
124
  def run_version = puts("maf #{VERSION}")
122
125
  def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
@@ -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