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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +11 -10
- data/assets/analyst +29 -51
- data/assets/coord +23 -20
- data/assets/dashboard +1 -5
- data/assets/dispatcher +69 -286
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/cli.rb +3 -0
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/generator.rb +1 -1
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +2 -2
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +6 -41
- data/lib/maf/flow/options.rb +4 -2
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +7 -11
- data/lib/maf/flow/report.rb +2 -4
- data/lib/maf/flow/role_files.rb +4 -19
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +4 -20
- data/lib/maf/menu.rb +1 -1
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/dispatch.rb +1 -1
- data/lib/maf/setup_agent/launcher.rb +15 -66
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +1 -3
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +140 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +134 -0
- data/lib/maf/shared/harnesses/codex.rb +173 -0
- data/lib/maf/shared/harnesses/cursor.rb +64 -0
- data/lib/maf/shared/harnesses/hermes.rb +112 -0
- data/lib/maf/shared/harnesses/opencode.rb +169 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team_command.rb +3 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +6 -26
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +4 -7
- data/lib/maf/workers.rb +27 -0
- metadata +30 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- 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
|
|
6
|
-
# .
|
|
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
|
-
|
|
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
|
data/lib/maf/uninstall/notes.rb
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
path = File.join(@project,
|
|
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
|
-
|
|
38
|
-
Dir.glob(File.join(@project,
|
|
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
|
|
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
|
data/lib/maf/uninstall/runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
63
|
-
def tracked
|
|
64
|
-
|
|
65
|
-
|
|
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
data/lib/maf/vm/login.rb
ADDED
|
@@ -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"
|
data/lib/maf/worker_control.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
28
|
-
share a Taskwarrior task board, file locks, and a knowledge graph through
|
|
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.
|
|
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: []
|