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
@@ -2,15 +2,14 @@
2
2
 
3
3
  module Maf
4
4
  module Uninstall
5
- # Removes the graphify server that an older maf wrote into the project's
6
- # .mcp.json and opencode.json. Today the server lives in .maf/mcp/. A
5
+ # Removes the graphify server that an older maf wrote into a project file
6
+ # (Harness#legacy_mcp_file). Today the server lives in .maf/mcp/. A
7
7
  # foreign entry and all other settings stay. A file goes only if nothing
8
8
  # else is left in it.
9
9
  class McpEntries
10
- PROJECT_FILES = { "claude" => ".mcp.json", "opencode" => "opencode.json" }.freeze
11
-
12
10
  def initialize(project)
13
- @files = Flow::McpConfig.targets.map { |harness, t| [File.join(project, PROJECT_FILES.fetch(harness)), t] }
11
+ harnesses = Maf::Shared::Harness.all.select { |harness| harness.mcp && harness.legacy_mcp_file }
12
+ @files = harnesses.map { |harness| [File.join(project, harness.legacy_mcp_file), harness.mcp] }
14
13
  end
15
14
 
16
15
  def steps
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../vm"
4
+
3
5
  module Maf
4
6
  module Uninstall
5
7
  # Tells the user what the uninstaller keeps on purpose.
@@ -7,7 +9,7 @@ module Maf
7
9
  def initialize(project) = @project = project
8
10
 
9
11
  def lines
10
- [kept_dirs, branches, global_hooks].compact
12
+ [kept_dirs, branches, global_hooks, vm].compact
11
13
  end
12
14
 
13
15
  private
@@ -22,8 +24,17 @@ module Maf
22
24
  "kept branches #{list.join(", ")} (delete with git branch -D)" if list.any?
23
25
  end
24
26
 
27
+ def vm
28
+ return unless Maf::Shared::Harness.on_path?(Vm::LIMACTL)
29
+
30
+ name = Vm.new([], @project).name
31
+ "kept the VM #{name} (delete with maf vm delete)" if vm_names.include?(name)
32
+ end
33
+
34
+ def vm_names = IO.popen([Vm::LIMACTL, "list", "--quiet"], err: File::NULL, &:readlines).map(&:strip)
35
+
25
36
  def global_hooks
26
- hooks = GLOBAL_HOOKS.select { |path| File.exist?(path) }
37
+ hooks = Uninstall.global_files.select { |path| File.exist?(path) }
27
38
  "kept global hooks #{hooks.join(", ")} (other projects can use them)" if hooks.any?
28
39
  end
29
40
  end
@@ -5,8 +5,6 @@ module Maf
5
5
  # Removes the role files in .maf/agents/ and the symlink of each harness
6
6
  # folder that points there.
7
7
  class RoleFiles
8
- DIRS = %w[claude opencode codex].map { |harness| [".maf", "agents", harness] }.freeze
9
-
10
8
  def initialize(project, manifest)
11
9
  @project = project
12
10
  @manifest = manifest
@@ -14,28 +12,28 @@ module Maf
14
12
 
15
13
  def steps
16
14
  links.map { |path| Owned.remove(path) } + project_files.map { |path| Owned.remove(path) } +
17
- @manifest.hermes_skills.select { |path| Owned.marked?(path) }.map { |path| hermes_step(path) }
15
+ @manifest.outside_role_files.select { |path| Owned.marked?(path) }.map { |path| outside_step(path) }
18
16
  end
19
17
 
20
18
  private
21
19
 
22
20
  def project_files
23
- DIRS.flat_map { |parts| Dir.glob(File.join(@project, *parts, "*.md")) }.select { |path| Owned.marked?(path) }
21
+ Dir.glob(File.join(@project, ".maf", "agents", "*", "*.md")).select { |path| Owned.marked?(path) }
24
22
  end
25
23
 
26
24
  def links = folder_links + file_links
27
25
 
28
26
  def folder_links
29
- Flow::HARNESS_DIRS.filter_map do |harness, dir|
30
- path = File.join(@project, dir)
31
- path if File.symlink?(path) && File.readlink(path) == Flow::AgentLinks.target(harness)
27
+ harnesses.filter_map do |harness|
28
+ path = File.join(@project, harness.role_dir)
29
+ path if File.symlink?(path) && File.readlink(path) == Flow::AgentLinks.target(harness.name)
32
30
  end
33
31
  end
34
32
 
35
33
  # The links of single role files in a harness folder with the user's own files.
36
34
  def file_links
37
- Flow::HARNESS_DIRS.flat_map do |harness, dir|
38
- Dir.glob(File.join(@project, dir, "*.md")).select { |path| file_link?(path, harness) }
35
+ harnesses.flat_map do |harness|
36
+ Dir.glob(File.join(@project, harness.role_dir, "*.md")).select { |path| file_link?(path, harness.name) }
39
37
  end
40
38
  end
41
39
 
@@ -43,7 +41,10 @@ module Maf
43
41
  File.symlink?(path) && File.readlink(path) == Flow::AgentLinks.file_target(harness, File.basename(path))
44
42
  end
45
43
 
46
- def hermes_step(path)
44
+ def harnesses = Maf::Shared::Harness.all.select(&:role_dir)
45
+
46
+ # A role file outside the project lives in a folder of its own.
47
+ def outside_step(path)
47
48
  Step.new("remove #{File.dirname(path)}", -> { FileUtils.rm(path) && Owned.prune(File.dirname(path)) })
48
49
  end
49
50
  end
@@ -40,14 +40,15 @@ module Maf
40
40
  manifest = Manifest.new(project)
41
41
  [VaultWatcher.new(project), Worktrees.new(project, @opts[:force]), Scripts.new(project),
42
42
  CommitGuard.new(project), DocGraphHooks.new(project), RoleFiles.new(project, manifest),
43
- ClaudeSettings.new(project), CodexHooks.new(project), McpEntries.new(project),
44
- MarkedFiles.new(project), LocalFiles.new(project), Coordination.new(project), manifest].flat_map(&:steps)
43
+ *Uninstall.harness_cleaners(project), *file_cleaners, manifest].flat_map(&:steps)
45
44
  end
46
45
 
46
+ def file_cleaners = [McpEntries, MarkedFiles, LocalFiles, Coordination, VmFiles].map { |step| step.new(project) }
47
+
47
48
  def apply(steps)
48
49
  abort "uninstall: cancelled" unless @opts[:yes] || confirmed?
49
50
  steps.each(&:run)
50
- EMPTY_DIRS.each { |parts| Owned.prune(File.join(project, *parts)) }
51
+ Uninstall.empty_dirs.each { |parts| Owned.prune(File.join(project, *parts)) }
51
52
  finish("removed the multi-agent flow from #{project}")
52
53
  end
53
54
 
@@ -28,7 +28,7 @@ module Maf
28
28
  # The files of the shared library. A file without the signature stays.
29
29
  def shared
30
30
  dir = File.join(@project, Bootstrap::MAF_DIR, Bootstrap::SHARED_DIR)
31
- Dir.glob(File.join(dir, "*.rb")).map { |path| [path, Bootstrap::SHARED_SIGNATURE] }
31
+ Dir.glob(File.join(dir, "**", "*.rb")).map { |path| [path, Bootstrap::SHARED_SIGNATURE] }
32
32
  end
33
33
  end
34
34
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ # VmFiles removes the gem file that maf vm create copied into .maf/vm/.
6
+ class VmFiles
7
+ def initialize(project) = @dir = File.join(project, ".maf", "vm")
8
+
9
+ def steps
10
+ return [] unless Dir.exist?(@dir)
11
+
12
+ [Step.new("remove #{@dir} (the gem file for maf vm)", -> { FileUtils.rm_rf(@dir) })]
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/maf/uninstall.rb CHANGED
@@ -20,18 +20,32 @@ require_relative "flow"
20
20
  require_relative "env_exclude"
21
21
  require_relative "local_exclude"
22
22
  require_relative "migrate"
23
+ require_relative "shared/sandbox"
23
24
 
24
25
  module Maf
26
+ # Uninstall removes the flow from a project. Each harness adds its own
27
+ # folders, files, and cleaners (Maf::Shared::Harness).
25
28
  module Uninstall
26
29
  KEPT_DIRS = %w[graphify-out].freeze
30
+ # The folders of the shared library in .maf/lib/. Children come before parents.
31
+ LIB_DIRS = [*%w[harness harnesses sandbox].map { |dir| [".maf", "lib", "maf", "shared", dir] },
32
+ %w[.maf lib maf shared], %w[.maf lib maf], %w[.maf lib]].freeze
27
33
 
28
34
  # Removed after all steps if empty. Children come before parents.
29
- EMPTY_DIRS = [%w[.claude agents], %w[.claude], %w[.opencode agents], %w[.opencode plugins], %w[.opencode],
30
- %w[.codex prompts], %w[.codex], %w[.maf bin], %w[.maf agents claude], %w[.maf agents opencode],
31
- %w[.maf agents codex], %w[.maf agents], %w[.maf worktrees],
32
- %w[.maf lib maf shared], %w[.maf lib maf], %w[.maf lib], %w[.maf]].freeze
35
+ def self.empty_dirs
36
+ harnesses = Maf::Shared::Harness.all
37
+ harnesses.flat_map(&:empty_dirs).map { |dir| dir.split("/") } + [%w[.maf bin]] +
38
+ harnesses.map { |harness| [".maf", "agents", harness.name] } +
39
+ [%w[.maf agents], %w[.maf worktrees], *LIB_DIRS, %w[.maf]]
40
+ end
41
+
42
+ # Files outside the project that uninstall keeps: other projects can use them.
43
+ def self.global_files = Maf::Shared::Harness.all.flat_map(&:global_files)
33
44
 
34
- GLOBAL_HOOKS = [File.join(Dir.home, ".hermes", "agent-hooks", "next-task.sh")].freeze
45
+ # The cleaners of each harness. untrack runs only the legacy cleaners.
46
+ def self.harness_cleaners(project, legacy_only: false)
47
+ Maf::Shared::Harness.all.flat_map { |h| h.legacy_cleaners(project) + (legacy_only ? [] : h.cleaners(project)) }
48
+ end
35
49
 
36
50
  # Step is one removal. --check prints the label and runs nothing.
37
51
  Step = Struct.new(:label, :work) do
@@ -56,4 +70,5 @@ require_relative "uninstall/marked_files"
56
70
  require_relative "uninstall/coordination"
57
71
  require_relative "uninstall/local_files"
58
72
  require_relative "uninstall/notes"
73
+ require_relative "uninstall/vm_files"
59
74
  require_relative "uninstall/runner"
data/lib/maf/untrack.rb CHANGED
@@ -11,7 +11,6 @@ module Maf
11
11
  # locally, and runs maf update. The user reviews the change and commits it.
12
12
  class Untrack
13
13
  USAGE = "Usage: maf untrack [--check] [--yes]"
14
- PATHS = [".maf", ".opencode/agents", ".codex/prompts", Bootstrap::OPENCODE_PLUGIN].freeze
15
14
 
16
15
  def initialize(root, argv)
17
16
  @root = root
@@ -30,8 +29,8 @@ module Maf
30
29
  private
31
30
 
32
31
  def plan
33
- cleaners = [Uninstall::MarkedFiles.new(@root, keep_rules: false), Uninstall::ClaudeSettings.new(@root),
34
- Uninstall::McpEntries.new(@root)]
32
+ cleaners = [Uninstall::MarkedFiles.new(@root, keep_rules: false),
33
+ *Uninstall.harness_cleaners(@root, legacy_only: true), Uninstall::McpEntries.new(@root)]
35
34
  [untrack_step, *cleaners.flat_map(&:steps)].compact
36
35
  end
37
36
 
@@ -59,29 +58,10 @@ module Maf
59
58
  $stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
60
59
  end
61
60
 
62
- # The tracked paths of the flow, and each tracked link into .maf/agents/.
63
- def tracked = (git_files(*PATHS).map { |path| top(path) } + agent_links + codex_hooks).uniq
64
-
65
- def top(path) = path.start_with?(".maf/") ? ".maf" : path
66
-
67
- def agent_links
68
- git_files(".claude/agents").select { |path| File.symlink?(File.join(@root, path)) && link_to_flow?(path) }
69
- end
70
-
71
- def link_to_flow?(path) = File.readlink(File.join(@root, path)).include?("#{Bootstrap::MAF_DIR}/agents")
72
-
73
- # A .codex/hooks.json with only the flow hooks belongs to the flow.
74
- def codex_hooks
75
- path = Flow::CodexHooks::FILE
76
- git_files(path).select { |file| (hook_commands(file) - Flow::CodexHooks::COMMANDS).empty? }
77
- end
78
-
79
- def hook_commands(file)
80
- data = JSON.parse(File.read(File.join(@root, file)))
81
- entries = data.fetch("hooks", {}).values.flatten
82
- entries.flat_map { |entry| entry.fetch("hooks", []) }.map { |hook| hook["command"] }
83
- rescue JSON::ParserError
84
- [nil]
61
+ # The tracked files of .maf/, and the tracked paths of each harness (Harness#untrack_paths).
62
+ def tracked
63
+ harness_paths = Maf::Shared::Harness.all.flat_map { |harness| harness.untrack_paths(@root, method(:git_files)) }
64
+ ((git_files(".maf").empty? ? [] : [".maf"]) + harness_paths).uniq
85
65
  end
86
66
 
87
67
  def git_files(*paths) = IO.popen(["git", "-C", @root, "ls-files", "--", *paths], err: File::NULL, &:readlines)
data/lib/maf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maf
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../prompt"
5
+
6
+ module Maf
7
+ class Vm
8
+ # Login copies the login of each harness from the host into the VM.
9
+ class Login
10
+ # The command runs in the VM. It writes stdin to a new file with mode 600,
11
+ # then moves the file to ~/DEST. An old file at DEST does not keep its mode.
12
+ WRITE = 'cd && umask 077 && mkdir -p "$(dirname "$1")" && tmp=$(mktemp "$1.XXXXXX") && ' \
13
+ 'cat > "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$1"'
14
+
15
+ def initialize(name, harnesses, mode, prompt = Prompt.new)
16
+ @name = name
17
+ @harnesses = harnesses
18
+ @mode = mode
19
+ @prompt = prompt
20
+ end
21
+
22
+ def run = @harnesses.each { |harness| copy?(harness) ? copy(harness) : hint(harness) }
23
+
24
+ private
25
+
26
+ def copy?(harness)
27
+ return false if harness.vm_credentials.empty? || @mode == :none
28
+
29
+ @mode == :all || @prompt.confirm?("Copy your #{harness.name} login from this computer into the VM?")
30
+ end
31
+
32
+ def copy(harness)
33
+ logins = harness.vm_credentials.filter_map { |credential| (text = credential.read) && [credential.dest, text] }
34
+ return hint(harness, "maf found no login on this computer. ") if logins.empty?
35
+
36
+ logins.each { |dest, text| written?(dest, text) || abort(not_running) }
37
+ @prompt.say("copied #{harness.name} login")
38
+ end
39
+
40
+ def not_running = "maf: cannot write to the VM #{@name}. Run: maf vm start"
41
+
42
+ def written?(dest, text)
43
+ command = [LIMACTL, "shell", "--workdir", "/", @name, "sh", "-c", WRITE, "sh", dest]
44
+ Open3.capture2e(*command, stdin_data: text).last.success?
45
+ end
46
+
47
+ def hint(harness, reason = "")
48
+ @prompt.say("#{harness.name}: #{reason}Log in in the VM: #{harness.vm_login_hint}")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ class Vm
5
+ VmOptions = Struct.new(:cpus, :memory, :disk, :gem, :login, keyword_init: true) do
6
+ def self.parse(argv, root)
7
+ options = new(cpus: 4, memory: 4, disk: 20, gem: "maf", login: $stdin.tty? ? :ask : :none)
8
+ parse!(options, argv)
9
+ options.gem = copy_gem(options.gem, root) if options.gem.end_with?(".gem")
10
+ options
11
+ end
12
+
13
+ def self.parse!(options, argv)
14
+ parser(options).parse!(argv.dup)
15
+ rescue OptionParser::ParseError => e
16
+ abort "maf: #{e.message}\n#{USAGE}"
17
+ end
18
+
19
+ def self.parser(options)
20
+ OptionParser.new(USAGE) do |o|
21
+ %i[cpus memory disk].each { |key| o.on("--#{key} N", Integer) { |value| options[key] = positive(value) } }
22
+ o.on("--gem FILE") { |file| options.gem = file }
23
+ o.on("--[no-]copy-logins") { |copy| options.login = copy ? :all : :none }
24
+ end
25
+ end
26
+
27
+ def self.positive(value)
28
+ value.positive? ? value : raise(OptionParser::InvalidArgument, "#{value} (use a number above 0)")
29
+ end
30
+
31
+ def self.copy_gem(file, root)
32
+ abort "maf: #{file} missing." unless File.exist?(file)
33
+
34
+ dest = File.join(root, ".maf", "vm", File.basename(file))
35
+ FileUtils.mkdir_p(File.dirname(dest))
36
+ FileUtils.cp(file, dest)
37
+ dest
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "shellwords"
5
+
6
+ module Maf
7
+ class Vm
8
+ # Template renders assets/lima/maf.yaml.erb for one project.
9
+ class Template
10
+ FILE = File.join(Flow::ROOT, "assets", "lima", "maf.yaml.erb")
11
+ TASKWARRIOR = "3.5.0"
12
+ # The VM checks the download. Change the checksum with the version.
13
+ TASKWARRIOR_SHA256 = "9ea64b411f8314414f440ec765dfdf5a86c9f6159df47e2f60cff3db6b31157a"
14
+ TASKWARRIOR_URL = "https://github.com/GothenburgBitFactory/taskwarrior/releases/download/" \
15
+ "v%<version>s/task-%<version>s.tar.gz"
16
+ HOSTS = { "darwin" => { vm_type: "vz", mount_type: "virtiofs" },
17
+ "linux" => { vm_type: "qemu", mount_type: "9p" } }.freeze
18
+
19
+ def initialize(root, options, harnesses)
20
+ @root = root
21
+ @options = options
22
+ @harnesses = harnesses
23
+ end
24
+
25
+ def render = ERB.new(File.read(FILE), trim_mode: "-").result_with_hash(**values)
26
+
27
+ def notes = @harnesses.reject(&:vm_install).map { |harness| "Install #{harness.name} in the VM yourself." }
28
+
29
+ private
30
+
31
+ def values
32
+ { project: @root, cpus: @options.cpus, memory: @options.memory, disk: @options.disk, gem_args: gem_args,
33
+ taskwarrior: TASKWARRIOR, taskwarrior_url: format(TASKWARRIOR_URL, version: TASKWARRIOR),
34
+ taskwarrior_sha256: TASKWARRIOR_SHA256,
35
+ harness_installs: @harnesses.select(&:vm_install).to_h { |h| [h.name, [h.command, h.vm_install]] },
36
+ **HOSTS.fetch(host) }
37
+ end
38
+
39
+ def gem_args = @options.gem == "maf" ? "maf -v #{Maf::VERSION.shellescape}" : @options.gem.shellescape
40
+
41
+ def host = RUBY_PLATFORM.include?("darwin") ? "darwin" : "linux"
42
+ end
43
+ end
44
+ end
data/lib/maf/vm.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # vm.rb - run the whole team in a Lima VM (https://lima-vm.io).
4
+ #
5
+ # maf vm create [--cpus N] [--memory GIB] [--disk GIB] [--gem FILE] [--[no-]copy-logins]
6
+ # maf vm login [--copy-logins]
7
+ # maf vm start | stop | delete | status
8
+ # maf vm shell [COMMAND ...]
9
+ #
10
+ # The VM mounts only the project folder, at the same path as on the host.
11
+ # The host is macOS (Apple Virtualization) or Linux (QEMU with KVM).
12
+ # Run maf, coord, and each agent in the VM. Read and review the work on the host.
13
+ require "digest"
14
+ require "erb"
15
+ require "fileutils"
16
+ require "json"
17
+ require "optparse"
18
+ require "tempfile"
19
+ require_relative "flow"
20
+ require_relative "shared/harness"
21
+
22
+ module Maf
23
+ # Vm runs limactl for the VM of the project.
24
+ class Vm
25
+ USAGE = "usage: maf vm create [--cpus N] [--memory GIB] [--disk GIB] [--gem FILE] [--[no-]copy-logins]" \
26
+ "|login|start|stop|delete|status|shell [COMMAND ...]"
27
+ ACTIONS = %w[create login start stop delete status shell].freeze
28
+ LIMACTL = "limactl"
29
+ INSTALL = "brew install lima (macOS), or Lima and QEMU from https://lima-vm.io/docs/installation/ (Linux)"
30
+
31
+ def initialize(argv, root)
32
+ @action, *@args = argv
33
+ @root = File.realpath(root)
34
+ end
35
+
36
+ def run
37
+ abort USAGE unless ACTIONS.include?(@action)
38
+ abort "maf: maf vm needs Lima. Install it: #{INSTALL}" unless Shared::Harness.on_path?(LIMACTL)
39
+
40
+ owned! unless @action == "create"
41
+ %w[start stop delete].include?(@action) ? lifecycle : send(@action)
42
+ end
43
+
44
+ def name = "maf-#{slug}-#{Digest::SHA256.hexdigest(@root)[0, 8]}"
45
+ def slug = File.basename(@root).downcase.gsub(/[^a-z0-9-]/, "-")[0, 30]
46
+
47
+ ENV_SCRIPT = '[ -f .maf/env.sh ] && . ./.maf/env.sh; export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH"; ' \
48
+ '[ $# -gt 0 ] || set -- bash -l; exec "$@"'
49
+
50
+ def shell_command = [LIMACTL, "shell", "--workdir", @root, name, "bash", "-c", ENV_SCRIPT, "bash", *@args]
51
+
52
+ private
53
+
54
+ def owned!
55
+ mounts = IO.popen([LIMACTL, "list", "--format", "{{json .Config.Mounts}}", name], err: File::NULL, &:read)
56
+ abort "maf: no VM for this project. Run: maf vm create" if mounts.strip.empty?
57
+ return if Array(JSON.parse(mounts)).any? { |mount| mount["location"] == @root }
58
+
59
+ abort "maf: the VM #{name} does not mount #{@root}. maf does not touch it."
60
+ end
61
+
62
+ def lifecycle
63
+ limactl(@action, *("--force" if @action == "delete"), name)
64
+ end
65
+
66
+ def create
67
+ options = VmOptions.parse(@args, @root)
68
+ template = Template.new(@root, options, harnesses)
69
+ Tempfile.create(["maf-vm", ".yaml"]) { |file| create_from(file.path, template.render) }
70
+ template.notes.each { |note| puts note }
71
+ Login.new(name, harnesses, options.login).run
72
+ end
73
+
74
+ def login = Login.new(name, harnesses, VmOptions.parse(@args, @root).login).run
75
+
76
+ def harnesses
77
+ ids = Shared::Project.manifest.fetch("agents", []).map { |agent| agent["harness"] }
78
+ ids.uniq.filter_map { |id| Shared::Harness[id] }
79
+ end
80
+
81
+ def create_from(path, yaml)
82
+ File.write(path, yaml)
83
+ limactl("create", "--tty=false", "--name=#{name}", path) && limactl("start", name)
84
+ end
85
+
86
+ def shell = Kernel.exec(*shell_command)
87
+ def status = limactl("list", name)
88
+ def limactl(*args) = system(LIMACTL, *args) || abort("maf: #{LIMACTL} #{args.first} failed.")
89
+ end
90
+ end
91
+
92
+ require_relative "vm/options"
93
+ require_relative "vm/template"
94
+ require_relative "vm/login"
@@ -44,6 +44,8 @@ module Maf
44
44
  def stop
45
45
  return puts("#{@worker} is already stopped.") unless live?
46
46
 
47
+ abort "maf: #{@worker}: maf cannot check pid #{pid}. Stop it with: kill #{pid}" unless ours?
48
+
47
49
  @entry["dispatch"] ? stop_dispatcher : stop_session
48
50
  end
49
51
 
@@ -84,13 +86,8 @@ module Maf
84
86
  def coord_dir = File.join(@root, ".maf", "coordination")
85
87
  def presence = read(File.join(coord_dir, "presence", "#{@worker}.json"))
86
88
  def pid = presence["pid"].to_i
87
- def live? = Shared::Processes.alive?(pid) && started_matches?
88
-
89
- # A pid can belong to a new process. The start time tells them apart.
90
- def started_matches?
91
- recorded = presence["started"].to_s
92
- recorded.empty? || recorded == Shared::Processes.started_at(pid)
93
- end
89
+ def live? = Shared::Processes.live?(presence)
90
+ def ours? = Shared::Processes.same?(pid, presence["started"])
94
91
 
95
92
  # The context-watch hook records the transcript of Claude Code and Codex
96
93
  # sessions. opencode has no such hook, so its idle state is unknown.
data/lib/maf/workers.rb CHANGED
@@ -8,6 +8,7 @@
8
8
  require "json"
9
9
  require "fileutils"
10
10
  require "time"
11
+ require_relative "shared/processes"
11
12
 
12
13
  module Maf
13
14
  class Workers
@@ -27,8 +28,34 @@ module Maf
27
28
  def remove(worker) = write(all.except(worker))
28
29
  def update(worker, fields) = add(worker, find(worker).to_h.merge(fields))
29
30
 
31
+ def dispatcher?(worker)
32
+ pid = find(worker)&.fetch("pid", nil)
33
+ pid ? Shared::Processes.same?(pid, started(worker)) : false
34
+ end
35
+
36
+ def started(worker)
37
+ entry = find(worker).to_h
38
+ presence = presence(worker)
39
+ recorded = entry["started"].to_s
40
+ recorded.empty? && presence["pid"].to_i == entry["pid"].to_i ? presence["started"] : entry["started"]
41
+ end
42
+
43
+ def running?(worker)
44
+ return dispatcher?(worker) unless started(worker).to_s.empty?
45
+
46
+ presence(worker)["pid"].to_i == find(worker).to_h["pid"].to_i && Shared::Processes.live?(presence(worker))
47
+ end
48
+
49
+ def presence(worker) = read(File.join(File.dirname(@path), "presence", "#{worker}.json"))
50
+
30
51
  private
31
52
 
53
+ def read(path)
54
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
55
+ rescue JSON::ParserError
56
+ {}
57
+ end
58
+
32
59
  def write(data)
33
60
  FileUtils.mkdir_p(File.dirname(@path))
34
61
  File.write(@path, JSON.pretty_generate(data))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dominik Alberski
@@ -24,9 +24,9 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.8'
26
26
  description: multi-agent-flow is a coordination layer for AI coding agents (Claude
27
- Code, Codex, opencode, Hermes). Each agent works in its own git worktree. The agents
28
- share a Taskwarrior task board, file locks, and a knowledge graph through the coord
29
- CLI.
27
+ Code, Codex, opencode, Cursor, Hermes). Each agent works in its own git worktree.
28
+ The agents share a Taskwarrior task board, file locks, and a knowledge graph through
29
+ the coord CLI.
30
30
  executables:
31
31
  - maf
32
32
  extensions: []
@@ -52,6 +52,7 @@ files:
52
52
  - assets/harness-hooks/next-task-hermes.sh
53
53
  - assets/harness-hooks/next-task.rb
54
54
  - assets/harness-hooks/session-guard.rb
55
+ - assets/lima/maf.yaml.erb
55
56
  - assets/taskrc.append
56
57
  - assets/vault
57
58
  - assets/worktree-env.example.rb
@@ -117,20 +118,37 @@ files:
117
118
  - lib/maf/setup_agent.rb
118
119
  - lib/maf/setup_agent/args.rb
119
120
  - lib/maf/setup_agent/dispatch.rb
120
- - lib/maf/setup_agent/hermes_launcher.rb
121
- - lib/maf/setup_agent/hermes_skill.rb
122
121
  - lib/maf/setup_agent/launcher.rb
123
122
  - lib/maf/setup_agent/manifest.rb
124
123
  - lib/maf/setup_agent/project.rb
124
+ - lib/maf/setup_agent/pty_relay.rb
125
125
  - lib/maf/setup_agent/role_file.rb
126
126
  - lib/maf/setup_agent/runtime_hooks.rb
127
127
  - lib/maf/setup_agent/worktree.rb
128
128
  - lib/maf/shared/git_exclude.rb
129
129
  - lib/maf/shared/git_identity.rb
130
+ - lib/maf/shared/harness.rb
131
+ - lib/maf/shared/harness/base.rb
132
+ - lib/maf/shared/harness/defaults.rb
133
+ - lib/maf/shared/harness/output.rb
134
+ - lib/maf/shared/harness/traits.rb
135
+ - lib/maf/shared/harness/vm_credential.rb
136
+ - lib/maf/shared/harnesses/claude.rb
137
+ - lib/maf/shared/harnesses/codex.rb
138
+ - lib/maf/shared/harnesses/cursor.rb
139
+ - lib/maf/shared/harnesses/hermes.rb
140
+ - lib/maf/shared/harnesses/opencode.rb
130
141
  - lib/maf/shared/peak_rate.rb
131
142
  - lib/maf/shared/processes.rb
132
143
  - lib/maf/shared/project.rb
133
144
  - lib/maf/shared/roles.rb
145
+ - lib/maf/shared/sandbox.rb
146
+ - lib/maf/shared/sandbox/bwrap.rb
147
+ - lib/maf/shared/sandbox/harness_files.rb
148
+ - lib/maf/shared/sandbox/links.rb
149
+ - lib/maf/shared/sandbox/paths.rb
150
+ - lib/maf/shared/sandbox/references.rb
151
+ - lib/maf/shared/sandbox/seatbelt.rb
134
152
  - lib/maf/team.rb
135
153
  - lib/maf/team_command.rb
136
154
  - lib/maf/uninstall.rb
@@ -150,16 +168,17 @@ files:
150
168
  - lib/maf/uninstall/runner.rb
151
169
  - lib/maf/uninstall/scripts.rb
152
170
  - lib/maf/uninstall/vault_watcher.rb
171
+ - lib/maf/uninstall/vm_files.rb
153
172
  - lib/maf/uninstall/worktrees.rb
154
173
  - lib/maf/untrack.rb
155
174
  - lib/maf/version.rb
175
+ - lib/maf/vm.rb
176
+ - lib/maf/vm/login.rb
177
+ - lib/maf/vm/options.rb
178
+ - lib/maf/vm/template.rb
156
179
  - lib/maf/worker_archive.rb
157
180
  - lib/maf/worker_control.rb
158
181
  - lib/maf/workers.rb
159
- - templates/claude.md.erb
160
- - templates/codex.md.erb
161
- - templates/hermes.md.erb
162
- - templates/opencode.md.erb
163
182
  - templates/role-stub.yml.erb
164
183
  - templates/roles.yml
165
184
  - templates/workflows/panel.md
@@ -187,7 +206,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
187
206
  - !ruby/object:Gem::Version
188
207
  version: '0'
189
208
  requirements: []
190
- rubygems_version: 4.0.22
209
+ rubygems_version: 4.0.20
191
210
  specification_version: 4
192
211
  summary: Run several AI coding agents in parallel on one repository.
193
212
  test_files: []