maf 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +174 -0
- data/README.md +29 -11
- data/assets/agents-contract.md +2 -0
- data/assets/analyst +29 -51
- data/assets/coord +168 -41
- data/assets/dashboard +1 -5
- data/assets/dispatcher +127 -298
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/install.md +6 -1
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/cli.rb +22 -9
- 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/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +3 -2
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +3 -3
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +14 -38
- data/lib/maf/flow/options.rb +33 -5
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +13 -14
- data/lib/maf/flow/report.rb +8 -9
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +7 -22
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +7 -21
- data/lib/maf/menu.rb +49 -15
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +3 -3
- data/lib/maf/setup_agent/launcher.rb +16 -65
- data/lib/maf/setup_agent/manifest.rb +9 -3
- 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 +11 -13
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +147 -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 +137 -0
- data/lib/maf/shared/harnesses/codex.rb +202 -0
- data/lib/maf/shared/harnesses/cursor.rb +120 -0
- data/lib/maf/shared/harnesses/hermes.rb +122 -0
- data/lib/maf/shared/harnesses/opencode.rb +192 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/roles.rb +4 -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.rb +5 -4
- data/lib/maf/team_command.rb +5 -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 +12 -25
- 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 +16 -8
- data/lib/maf/worker_list.rb +83 -0
- data/lib/maf/workers.rb +27 -0
- data/templates/roles.yml +14 -0
- metadata +33 -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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require_relative "../project"
|
|
7
|
+
require_relative "references"
|
|
8
|
+
require_relative "harness_files"
|
|
9
|
+
|
|
10
|
+
module Maf
|
|
11
|
+
module Shared
|
|
12
|
+
module Sandbox
|
|
13
|
+
# Paths holds the real paths of a sandbox.
|
|
14
|
+
class Paths
|
|
15
|
+
COMMON = ["/tmp", "/var/tmp"].freeze
|
|
16
|
+
PROTECTED = [".maf/sandbox.json", ".maf/bin", ".maf/lib", ".maf/env.sh", ".maf/coordination/harness-hooks",
|
|
17
|
+
".maf/claude", ".maf/mcp", ".git/hooks", ".git/config", ".git/commondir",
|
|
18
|
+
".git/config.worktree"].freeze
|
|
19
|
+
COORD_PROTECTED = %w[message-hooks taskrc worktree-env.rb taskdata/hooks].freeze
|
|
20
|
+
COORD_DIRS = %w[message-hooks taskdata/hooks].freeze
|
|
21
|
+
PROTECTED_DIRS = [".maf/bin", ".maf/lib", ".maf/claude", ".maf/mcp", ".git/hooks"].freeze
|
|
22
|
+
WORKTREE_PROTECTED = [".maf/bin", ".maf/lib", ".maf/env.sh", ".maf/coordination/harness-hooks"].freeze
|
|
23
|
+
FLOW_CONFIG = %w[.maf/claude/settings.json .maf/mcp/*.json].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :root
|
|
26
|
+
|
|
27
|
+
def initialize(harnesses, root, coord_dir, extra)
|
|
28
|
+
@harness = HarnessFiles.new(harnesses, ->(paths) { real(paths) })
|
|
29
|
+
@root = root
|
|
30
|
+
@coord_dir = coord_dir || File.join(root, ".maf", "coordination")
|
|
31
|
+
@extra = extra.map { |path| File.expand_path(path, root) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dirs = @dirs ||= real([@root, @coord_dir, *@extra, ENV.fetch("TMPDIR", nil), *COMMON])
|
|
35
|
+
def harness_dirs = @harness.dirs
|
|
36
|
+
def state = @harness.state
|
|
37
|
+
def harness_files = @harness.locked
|
|
38
|
+
def placeholders = @harness.placeholders
|
|
39
|
+
def prefixes = @harness.prefixes
|
|
40
|
+
|
|
41
|
+
def references
|
|
42
|
+
keep = ->(path) { writable?(path) }
|
|
43
|
+
References.of(@harness.config + project_config + flow_config, @root, keep) +
|
|
44
|
+
worktrees.flat_map { |dir| References.of(config_in(dir), dir, keep) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def existing = dirs.select { |dir| File.exist?(dir) }
|
|
48
|
+
def sockets = real([@root, @coord_dir])
|
|
49
|
+
|
|
50
|
+
def protected
|
|
51
|
+
@protected ||= begin
|
|
52
|
+
paths = [*PROTECTED.map { |path| File.join(@root, path) }, *project_config, *coord_files, *worktree_links,
|
|
53
|
+
*worktree_files, *submodule_files, *harness_files, *references]
|
|
54
|
+
(real(paths) + links(paths)).uniq
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def project_config = config_in(@root)
|
|
59
|
+
def config_in(dir) = Harness.all.flat_map(&:sandbox_project).uniq.map { |path| File.join(dir, path) }
|
|
60
|
+
def flow_config = FLOW_CONFIG.flat_map { |glob| Dir.glob(File.join(@root, glob)) }
|
|
61
|
+
|
|
62
|
+
def protected_dirs
|
|
63
|
+
real([*PROTECTED_DIRS.map { |path| File.join(@root, path) }, *COORD_DIRS.map { |name| coord_file(name) }])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def coord_files = COORD_PROTECTED.map { |name| coord_file(name) }
|
|
67
|
+
def coord_file(name) = File.join(@coord_dir, name)
|
|
68
|
+
|
|
69
|
+
def worktree_links
|
|
70
|
+
Dir.glob(File.join(@root, Project::WORKTREES_DIR, "*", ".git")) +
|
|
71
|
+
Dir.glob(File.join(@root, ".git", "worktrees", "*", "{commondir,gitdir,config.worktree}"))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def worktree_files
|
|
75
|
+
worktrees.flat_map do |dir|
|
|
76
|
+
[*WORKTREE_PROTECTED.map { |path| File.join(dir, path) }, *config_in(dir)]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
GIT_FILES = %w[config hooks].freeze
|
|
81
|
+
|
|
82
|
+
def submodule_files
|
|
83
|
+
dirs = Dir.glob(File.join(@root, ".git", "modules", "**", "HEAD")).map { |head| File.dirname(head) }
|
|
84
|
+
dirs = dirs.select { |dir| File.file?(File.join(dir, "config")) }
|
|
85
|
+
dirs.flat_map { |dir| GIT_FILES.map { |name| File.join(dir, name) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def worktrees = Dir.glob(File.join(@root, Project::WORKTREES_DIR, "*"))
|
|
89
|
+
|
|
90
|
+
def parents
|
|
91
|
+
protected.flat_map { |path| parents_of(path) }.uniq.sort_by(&:size)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def parents_of(path)
|
|
97
|
+
top = (dirs + harness_dirs).select { |dir| path.start_with?("#{dir}/") }.max_by(&:size)
|
|
98
|
+
top ? ancestors(path).take_while { |dir| dir.size >= top.size } : []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def writable?(path)
|
|
102
|
+
real_path = real([path]).first
|
|
103
|
+
dirs.any? { |dir| real_path.start_with?("#{dir}/") }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ancestors(path) = (dir = File.dirname(path)) == path ? [] : [dir, *ancestors(dir)]
|
|
107
|
+
def real(paths) = paths.compact.reject(&:empty?).map { |path| resolve(File.expand_path(path)) }.uniq
|
|
108
|
+
|
|
109
|
+
def resolve(path)
|
|
110
|
+
File.realpath(path)
|
|
111
|
+
rescue SystemCallError
|
|
112
|
+
File.exist?(File.dirname(path)) ? in_real_dir(path) : path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def links(paths)
|
|
116
|
+
symlinks = paths.map { |path| File.expand_path(path) }.select { |path| File.symlink?(path) }
|
|
117
|
+
symlinks.map { |link| in_real_dir(link) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def in_real_dir(path) = File.join(File.realpath(File.dirname(path)), File.basename(path))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# References finds the files that a config file of a harness names.
|
|
12
|
+
module References
|
|
13
|
+
# A path in a command or a setting: ~/x, $HOME/x, ${HOME}/x, or /x.
|
|
14
|
+
PATH = %r{(?:~|\$\{?HOME\}?)?/[^\s"'`;|&()<>,\\]+}
|
|
15
|
+
# A relative path with a folder (scripts/hook.sh). A hook runs in the project folder.
|
|
16
|
+
RELATIVE = /(?<![\w.\/~}$-])[\w.-]+(?:\/[\w.-]+)+/
|
|
17
|
+
# An import in a Markdown file: @path, at the start of a line or after a space.
|
|
18
|
+
# A period or a comma after the path ends the sentence.
|
|
19
|
+
IMPORT = /(?:^|\s)@([^\s]+?)[.,;:!?)]*(?=\s|$)/
|
|
20
|
+
# Claude Code sets this variable to the project folder for a hook. It becomes ".", so RELATIVE finds the path.
|
|
21
|
+
PROJECT_DIR = /\$\{?CLAUDE_PROJECT_DIR\}?/
|
|
22
|
+
DEPTH = 5
|
|
23
|
+
MAX_SIZE = 1_000_000
|
|
24
|
+
|
|
25
|
+
def self.of(files, root, keep) = walk(files, root, DEPTH).select(&keep)
|
|
26
|
+
|
|
27
|
+
def self.walk(files, root, depth)
|
|
28
|
+
found = files.flat_map { |file| named_in(file, root) }.uniq - files
|
|
29
|
+
depth.zero? || found.empty? ? found : (found + walk(found, root, depth - 1)).uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.named_in(file, root)
|
|
33
|
+
text = read(file)
|
|
34
|
+
paths = file.end_with?(".md") ? imports(text, file) : commands(text, file, root)
|
|
35
|
+
paths.select { |path| File.file?(path) }
|
|
36
|
+
rescue SystemCallError, ArgumentError
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.imports(text, file) = text.scan(IMPORT).flatten.map { |path| expand(path, file) }
|
|
41
|
+
|
|
42
|
+
def self.commands(text, file, root)
|
|
43
|
+
text = text.gsub(PROJECT_DIR, ".")
|
|
44
|
+
text.scan(PATH).map { |path| expand(path, file) } +
|
|
45
|
+
text.scan(RELATIVE).map { |path| File.expand_path(path, root) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.read(file)
|
|
49
|
+
text = File.file?(file) && File.size(file) < MAX_SIZE ? File.read(file) : ""
|
|
50
|
+
file.end_with?(".json") ? without_permissions(text) : text
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.without_permissions(text)
|
|
54
|
+
data = JSON.parse(text)
|
|
55
|
+
data.is_a?(Hash) ? JSON.generate(data.except("permissions")) : text
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
text
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.expand(path, file) = File.expand_path(path.sub(/\A\$\{?HOME\}?/, "~"), File.dirname(file))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# Seatbelt runs a command in a macOS sandbox-exec profile.
|
|
12
|
+
module Seatbelt
|
|
13
|
+
COMMAND = "/usr/bin/sandbox-exec"
|
|
14
|
+
DEVICES = '(literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") ' \
|
|
15
|
+
'(regex #"^/dev/tty") (regex #"^/dev/fd/") (regex #"^/dev/ptmx")'
|
|
16
|
+
DNS = '(path-literal "/private/var/run/mDNSResponder")'
|
|
17
|
+
TMP_PREFIX = "maf-sandbox-"
|
|
18
|
+
SERVICES = "(deny mach-lookup (global-name-regex #\"^com\\.apple\\.(coreservices|lsd)\\.\"))\n" \
|
|
19
|
+
"(deny appleevent-send)\n"
|
|
20
|
+
|
|
21
|
+
def self.wrap(cmd, paths)
|
|
22
|
+
tmp = private_tmp(paths.root)
|
|
23
|
+
[COMMAND, "-p", profile(paths, tmp), "env", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache", *cmd]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.available? = File.executable?(COMMAND)
|
|
27
|
+
def self.require! = available? || abort("maf: sandbox mode seatbelt needs #{COMMAND} (macOS only).")
|
|
28
|
+
|
|
29
|
+
def self.private_tmp(root) = own_dir(project_tmp(root))
|
|
30
|
+
|
|
31
|
+
def self.project_tmp(root)
|
|
32
|
+
File.join(File.realpath("/tmp"), "#{TMP_PREFIX}#{Process.uid}-#{Digest::SHA256.hexdigest(root)[0, 12]}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.own_dir(dir)
|
|
36
|
+
make(dir)
|
|
37
|
+
stat = File.lstat(dir)
|
|
38
|
+
return dir if stat.directory? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
39
|
+
|
|
40
|
+
abort "maf: #{dir} belongs to another user or is open to others. Remove it."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.make(dir)
|
|
44
|
+
Dir.mkdir(dir, 0o700)
|
|
45
|
+
rescue Errno::EEXIST
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.profile(paths, tmp)
|
|
50
|
+
"(version 1)\n(allow default)\n(deny file-write*)\n#{writes(paths)}#{protects(paths)}" \
|
|
51
|
+
"#{sockets([*paths.sockets, tmp])}#{SERVICES}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.writes(paths)
|
|
55
|
+
rules = paths.dirs.map { |dir| "(subpath #{quote(dir)})" } +
|
|
56
|
+
paths.prefixes.map { |prefix| "(prefix #{quote(prefix)})" } +
|
|
57
|
+
paths.state.filter_map { |pattern| regex(pattern) }
|
|
58
|
+
"(allow file-write* #{DEVICES}\n #{rules.join("\n ")})\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.regex(pattern)
|
|
62
|
+
return if pattern.include?('"')
|
|
63
|
+
|
|
64
|
+
"(regex #\"^#{escape(pattern).gsub("*", "[^/]*")}(/|$)\")"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.escape(pattern) = pattern.gsub(/[.^$|()\[\]{}+?\\]/) { |char| "\\#{char}" }
|
|
68
|
+
|
|
69
|
+
# A later rule wins, so these denials come after the allowed folders.
|
|
70
|
+
def self.protects(paths)
|
|
71
|
+
rules = paths.protected.map { |path| "(subpath #{quote(path)})" } +
|
|
72
|
+
paths.parents.map { |dir| "(literal #{quote(dir)})" }
|
|
73
|
+
"(deny file-write*\n #{rules.join("\n ")})\n"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.sockets(dirs)
|
|
77
|
+
allowed = [DNS, *dirs.map { |dir| "(subpath #{quote(dir)})" }]
|
|
78
|
+
rules = allowed.map { |path| "(allow network-outbound (remote unix-socket #{path}))\n" }
|
|
79
|
+
"(deny network-outbound (remote unix-socket))\n#{rules.join}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.quote(path) = "\"#{path.gsub(/["\\]/) { |char| "\\#{char}" }}\""
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "project"
|
|
8
|
+
require_relative "harness"
|
|
9
|
+
require_relative "sandbox/paths"
|
|
10
|
+
require_relative "sandbox/seatbelt"
|
|
11
|
+
require_relative "sandbox/bwrap"
|
|
12
|
+
require_relative "sandbox/links"
|
|
13
|
+
|
|
14
|
+
module Maf
|
|
15
|
+
module Shared
|
|
16
|
+
# Sandbox runs an agent command in the OS sandbox of the mode in .maf/sandbox.json.
|
|
17
|
+
module Sandbox
|
|
18
|
+
BACKENDS = { "seatbelt" => Seatbelt, "bwrap" => Bwrap }.freeze
|
|
19
|
+
MODES = ["off", "auto", *BACKENDS.keys].freeze
|
|
20
|
+
POLICY = File.join(".maf", "sandbox.json")
|
|
21
|
+
|
|
22
|
+
def self.policy
|
|
23
|
+
path = File.join(Project.root, POLICY)
|
|
24
|
+
return {} unless File.exist?(path)
|
|
25
|
+
|
|
26
|
+
JSON.parse(File.read(path)).tap { |data| data.is_a?(Hash) || abort("maf: #{POLICY} must hold a JSON object.") }
|
|
27
|
+
rescue SystemCallError, JSON::ParserError => e
|
|
28
|
+
abort "maf: cannot read #{POLICY}: #{e.message.lines.first.strip}. Fix the file, or delete it."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.mode(policy = self.policy)
|
|
32
|
+
mode = ENV.fetch("MAF_SANDBOX", nil).to_s
|
|
33
|
+
mode = policy["mode"] || "off" if mode.empty?
|
|
34
|
+
abort "maf: unknown sandbox mode '#{mode}' (use #{MODES.join(", ")})" unless MODES.include?(mode)
|
|
35
|
+
(mode == "auto" ? native : mode).tap { |name| BACKENDS[name]&.require! }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.native = RUBY_PLATFORM.include?("darwin") ? "seatbelt" : "bwrap"
|
|
39
|
+
|
|
40
|
+
def self.wrap(cmd, harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
41
|
+
policy = self.policy
|
|
42
|
+
mode = mode(policy)
|
|
43
|
+
mode == "off" ? cmd : BACKENDS[mode].wrap(harness.sandboxed(cmd), paths(harness, policy, coord_dir))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.keeping_links(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
47
|
+
links = protected_links(harness, coord_dir)
|
|
48
|
+
yield
|
|
49
|
+
ensure
|
|
50
|
+
Links.restore(links || {})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.protected_links(harness, coord_dir)
|
|
54
|
+
policy = self.policy
|
|
55
|
+
mode(policy) == "bwrap" && !Bwrap.inside? ? Links.record(paths(harness, policy, coord_dir).protected) : {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.describe(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
59
|
+
policy = self.policy
|
|
60
|
+
mode = mode(policy)
|
|
61
|
+
mode == "off" ? "off" : "#{mode}, #{writes(paths(harness, policy, coord_dir))}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.writes(paths)
|
|
65
|
+
state = paths.harness_dirs.select { |dir| File.exist?(dir) }
|
|
66
|
+
"writes to #{paths.existing.join(", ")}#{", and the state files in #{state.join(", ")}" if state.any?}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.paths(harness, policy, coord_dir)
|
|
70
|
+
protect_policy
|
|
71
|
+
Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, Array(policy["write"]))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.project_harnesses = Project.manifest.fetch("agents", []).filter_map { |a| Harness[a["harness"]] }
|
|
75
|
+
|
|
76
|
+
def self.protect_policy
|
|
77
|
+
path = File.join(Project.root, POLICY)
|
|
78
|
+
File.write(path, "{}\n") unless File.exist?(path)
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/maf/team.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# team.rb - add, replace, and retire workers in one command.
|
|
4
4
|
#
|
|
5
|
-
# maf prepare HARNESS ROLE[_WORKER] [--model M] [--dispatch | --interactive] [--replace WORKER]
|
|
5
|
+
# maf prepare HARNESS ROLE[_WORKER] [--model M] [--effort L] [--dispatch | --interactive] [--replace WORKER]
|
|
6
6
|
# maf retire WORKER
|
|
7
7
|
#
|
|
8
8
|
# The project manager runs these commands. `maf prepare` adds the role file,
|
|
@@ -72,7 +72,7 @@ module Maf
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
def prepare
|
|
75
|
-
Maf.flow("--agent", spec) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
|
|
75
|
+
Maf.flow("--agent", spec, *effort_flag) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
|
|
76
76
|
worktree = SetupAgent::Worktree.ensure(@args.role, @args.worker_id, @args.harness)
|
|
77
77
|
SetupAgent::RoleFile.copy(@root, worktree.dir, @args.harness, @args.role)
|
|
78
78
|
archive_on_harness_change
|
|
@@ -87,6 +87,7 @@ module Maf
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def spec = [@args.harness, @args.role, @args.model].compact.join(":")
|
|
90
|
+
def effort_flag = @args.effort ? ["--effort", "#{@args.role}=#{@args.effort}"] : []
|
|
90
91
|
|
|
91
92
|
def workers_after
|
|
92
93
|
all = Workers.at(@root).all
|
|
@@ -98,12 +99,12 @@ module Maf
|
|
|
98
99
|
def start_dispatcher
|
|
99
100
|
Dir.chdir(@root)
|
|
100
101
|
args = [@args.harness, "#{@args.role}_#{@args.worker_id}", "--dispatch", "--detach"]
|
|
101
|
-
SetupAgent.run(
|
|
102
|
+
SetupAgent.run(args + { "--model" => @args.model, "--effort" => @args.effort }.compact.flatten)
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
def entry(dir)
|
|
105
106
|
{ "role" => @args.role, "worker_id" => @args.worker_id, "harness" => @args.harness,
|
|
106
|
-
"model" => @args.model, "dispatch" => @args.dispatch, "dir" => dir }.compact
|
|
107
|
+
"model" => @args.model, "effort" => @args.effort, "dispatch" => @args.dispatch, "dir" => dir }.compact
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
def print_next_steps
|
data/lib/maf/team_command.rb
CHANGED
|
@@ -32,15 +32,17 @@ module Maf
|
|
|
32
32
|
|
|
33
33
|
def line(id, entry)
|
|
34
34
|
mode = entry["dispatch"] ? "dispatch" : "interactive"
|
|
35
|
-
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry
|
|
35
|
+
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], model(entry), mode, state(id, entry)]
|
|
36
36
|
.join(" ")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def
|
|
39
|
+
def model(entry) = [entry["model"] || "-", entry["effort"]].compact.join("/")
|
|
40
|
+
|
|
41
|
+
def state(id, entry)
|
|
40
42
|
pid = entry["pid"]
|
|
41
43
|
return "-" unless pid
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
Workers.at(@root).running?(id) ? "running (pid #{pid})" : "stopped"
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# The budget keys replace the old budget. Keep every other manifest key,
|
|
@@ -2,25 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module Maf
|
|
4
4
|
module Uninstall
|
|
5
|
-
# .maf/config.json lists the generated agents.
|
|
6
|
-
# the project, so only the manifest tells which ones belong to it.
|
|
5
|
+
# .maf/config.json lists the generated agents. Some harnesses keep role
|
|
6
|
+
# files outside the project, so only the manifest tells which ones belong to it.
|
|
7
7
|
class Manifest
|
|
8
8
|
def initialize(project)
|
|
9
9
|
@project = project
|
|
10
10
|
@path = File.join(project, ".maf/config.json")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def steps = File.exist?(
|
|
13
|
+
def steps = [@path, policy].select { |path| File.exist?(path) }.map { |path| Owned.remove(path) }
|
|
14
|
+
def policy = File.join(@project, Maf::Shared::Sandbox::POLICY)
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# The role files of the harnesses that keep them outside .maf/agents/.
|
|
17
|
+
def outside_role_files
|
|
18
|
+
data.fetch("agents", []).filter_map do |agent|
|
|
19
|
+
harness = Maf::Shared::Harness[agent["harness"]]
|
|
20
|
+
harness.role_destination(@project, agent["role"], data) if harness && !harness.role_path(agent["role"])
|
|
21
|
+
end
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
private
|
|
21
25
|
|
|
22
|
-
def hermes_dir = data.fetch("hermes_dir", Flow::DEFAULT_HERMES_DIR)
|
|
23
|
-
|
|
24
26
|
def data
|
|
25
27
|
@data ||= File.exist?(@path) ? (JSON.parse(File.read(@path)) rescue {}) : {}
|
|
26
28
|
end
|
|
@@ -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"
|