maf 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +29 -11
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/analyst +29 -51
  6. data/assets/coord +168 -41
  7. data/assets/dashboard +1 -5
  8. data/assets/dispatcher +127 -298
  9. data/assets/harness-hooks/context-watch.rb +5 -4
  10. data/assets/lima/maf.yaml.erb +61 -0
  11. data/install.md +6 -1
  12. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  13. data/lib/maf/bootstrap/script_planner.rb +1 -1
  14. data/lib/maf/bootstrap.rb +4 -0
  15. data/lib/maf/cli.rb +22 -9
  16. data/lib/maf/flow/agent_links.rb +2 -2
  17. data/lib/maf/flow/bootstrapper.rb +5 -5
  18. data/lib/maf/flow/codex_hooks.rb +12 -1
  19. data/lib/maf/flow/effort_report.rb +20 -0
  20. data/lib/maf/flow/generator.rb +3 -2
  21. data/lib/maf/flow/harness_linker.rb +2 -2
  22. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  23. data/lib/maf/flow/hook_files.rb +6 -0
  24. data/lib/maf/flow/hook_installer.rb +6 -20
  25. data/lib/maf/flow/manifest.rb +3 -3
  26. data/lib/maf/flow/mcp_config.rb +8 -22
  27. data/lib/maf/flow/mcp_installer.rb +3 -3
  28. data/lib/maf/flow/models.rb +14 -38
  29. data/lib/maf/flow/options.rb +33 -5
  30. data/lib/maf/flow/prompt_builder.rb +7 -14
  31. data/lib/maf/flow/prompt_text.rb +13 -14
  32. data/lib/maf/flow/report.rb +8 -9
  33. data/lib/maf/flow/role_catalog.rb +1 -1
  34. data/lib/maf/flow/role_files.rb +7 -22
  35. data/lib/maf/flow/roster.rb +6 -3
  36. data/lib/maf/flow/validator.rb +2 -2
  37. data/lib/maf/flow.rb +7 -21
  38. data/lib/maf/menu.rb +49 -15
  39. data/lib/maf/migrate/role_files.rb +3 -1
  40. data/lib/maf/retire.rb +42 -6
  41. data/lib/maf/setup_agent/args.rb +16 -10
  42. data/lib/maf/setup_agent/dispatch.rb +3 -3
  43. data/lib/maf/setup_agent/launcher.rb +16 -65
  44. data/lib/maf/setup_agent/manifest.rb +9 -3
  45. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  46. data/lib/maf/setup_agent/role_file.rb +1 -1
  47. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  48. data/lib/maf/setup_agent.rb +11 -13
  49. data/lib/maf/shared/harness/base.rb +57 -0
  50. data/lib/maf/shared/harness/defaults.rb +147 -0
  51. data/lib/maf/shared/harness/output.rb +55 -0
  52. data/lib/maf/shared/harness/traits.rb +35 -0
  53. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  54. data/lib/maf/shared/harness.rb +12 -0
  55. data/lib/maf/shared/harnesses/claude.rb +137 -0
  56. data/lib/maf/shared/harnesses/codex.rb +202 -0
  57. data/lib/maf/shared/harnesses/cursor.rb +120 -0
  58. data/lib/maf/shared/harnesses/hermes.rb +122 -0
  59. data/lib/maf/shared/harnesses/opencode.rb +192 -0
  60. data/lib/maf/shared/processes.rb +20 -0
  61. data/lib/maf/shared/roles.rb +4 -0
  62. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  63. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  64. data/lib/maf/shared/sandbox/links.rb +28 -0
  65. data/lib/maf/shared/sandbox/paths.rb +124 -0
  66. data/lib/maf/shared/sandbox/references.rb +64 -0
  67. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  68. data/lib/maf/shared/sandbox.rb +84 -0
  69. data/lib/maf/team.rb +5 -4
  70. data/lib/maf/team_command.rb +5 -3
  71. data/lib/maf/uninstall/manifest.rb +10 -8
  72. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  73. data/lib/maf/uninstall/notes.rb +13 -2
  74. data/lib/maf/uninstall/role_files.rb +11 -10
  75. data/lib/maf/uninstall/runner.rb +4 -3
  76. data/lib/maf/uninstall/scripts.rb +1 -1
  77. data/lib/maf/uninstall/vm_files.rb +16 -0
  78. data/lib/maf/uninstall.rb +20 -5
  79. data/lib/maf/untrack.rb +12 -25
  80. data/lib/maf/version.rb +1 -1
  81. data/lib/maf/vm/login.rb +52 -0
  82. data/lib/maf/vm/options.rb +41 -0
  83. data/lib/maf/vm/template.rb +44 -0
  84. data/lib/maf/vm.rb +94 -0
  85. data/lib/maf/worker_control.rb +16 -8
  86. data/lib/maf/worker_list.rb +83 -0
  87. data/lib/maf/workers.rb +27 -0
  88. data/templates/roles.yml +14 -0
  89. metadata +33 -11
  90. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  91. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  92. data/templates/claude.md.erb +0 -16
  93. data/templates/codex.md.erb +0 -7
  94. data/templates/hermes.md.erb +0 -12
  95. data/templates/opencode.md.erb +0 -24
@@ -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(@args.model ? args + ["--model", @args.model] : args)
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
@@ -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["model"] || "-", mode, state(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 state(entry)
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
- Shared::Processes.alive?(pid) ? "running (pid #{pid})" : "stopped"
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. Hermes skills live outside
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?(@path) ? [Owned.remove(@path)] : []
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
- def hermes_skills
16
- roles = data.fetch("agents", []).select { |a| a["harness"] == "hermes" }.map { |a| a["role"] }
17
- roles.map { |role| File.join(hermes_dir, "#{File.basename(@project)}-#{role}", "SKILL.md") }
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 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"