maf 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d92abf8bbcca7ba5044ac6185534e883a55a1387e05460ac4142e1d89f3d13e
4
- data.tar.gz: 25eaa46e4ed438fef70a08841137fc87c7ede35dfe9c09ff4d053a351e11e9ef
3
+ metadata.gz: a2844c9f128a0b025a9a1c933091642b9e885f069d3bfe7daff77f2fe3659c56
4
+ data.tar.gz: c464cfff417c58d40add531944b4ec12a93a5251510dc01d7f2d81a0a7905997
5
5
  SHA512:
6
- metadata.gz: 96324cff6b379cb0ec71cbe0d8a15d127a950ddfb7e3997012f4f9b6c4759508a34668fc06a7702d04030ebf65875335c05a6a5836f7959422cbaf107d6abe92
7
- data.tar.gz: 9faa6836a004d143d9bcdfc7852d4479b2f9b43f40d09efcab44c32343607b9bcf73949c7e62235b772f817d37845e0e2a5955deefab1c576477a962798e8aa2
6
+ metadata.gz: d906ee9673550164f3c61c195eee88a41173f80443176c5be7809a89d2da32036f8c6ad03620ca8ddadb8994a345a565602b660e0a150563d4411c6c0e896fae
7
+ data.tar.gz: 3fe01e2df87c0676ced712531aa44ec1935d8a5d5982d1521e765a3d846a4d9fd524485076738ba1c35927f8eb2cb00fea6db174d3fb570ada0565b19ebdeff1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-10-21
6
+
7
+ - Fix: in the macOS sandbox, a worker that the project manager started failed on each run with
8
+ `sandbox_apply: Operation not permitted`. macOS cannot start a sandbox inside a sandbox. The
9
+ sandbox now sets `MAF_SEATBELT=1`. In the sandbox, `maf prepare --dispatch` prepares the worker
10
+ and prints the start commands, and a dispatcher stops at once with the reason in its log.
11
+ - The project manager no longer adds workers on its own. Without a team budget, `maf prepare`
12
+ refuses when an agent runs it. Only the user sets the budget: `maf team set` refuses when an
13
+ agent runs it.
14
+ - Backup of the flow config. Each maf command copies `.maf/config.json`, `roles.yml`,
15
+ `workflow.md`, and `sandbox.json` to `.git/maf/backup/` when they changed, and keeps 10
16
+ copies. New command `maf restore [--list] [BACKUP] [--force]` puts them back and runs
17
+ `maf update`. When `.maf/` exists without `config.json`, maf names the backup. Before, a
18
+ merge of the `maf untrack` commit into a branch that still tracked the flow deleted the
19
+ config, and the agents, `base_branch`, and `github` settings were lost.
20
+
5
21
  ## [0.3.0] - 2026-10-21
6
22
 
7
23
  - Fix: each dispatched Hermes run failed. Hermes 0.22 refuses `--run-budget` with `--format
data/README.md CHANGED
@@ -192,6 +192,8 @@ multi_agent_flow/
192
192
  flow/workflow.rb # reads .maf/workflow.md for the architect prompt
193
193
  flow/mcp_config.rb # writes the graphify MCP server into .maf/mcp/
194
194
  untrack.rb # maf untrack: removes an older install from git
195
+ backup.rb # copies of the flow config in .git/maf/backup
196
+ restore.rb # maf restore: puts the flow config back from a backup
195
197
  local_exclude.rb # the flow block in .git/info/exclude
196
198
  shared/ # stdlib-only code that the maf CLI and the scripts share (installed as .maf/lib/maf/shared/)
197
199
  shared/harness/ # the harness registry, the default of each harness method, shared modules
@@ -234,6 +236,7 @@ multi_agent_flow/
234
236
  worker_control_test.rb # tests for maf worker
235
237
  worker_list_test.rb # tests for maf worker list
236
238
  untrack_test.rb # tests for maf untrack
239
+ backup_test.rb # tests for the backup and maf restore
237
240
  analyst_test.rb # tests for the analyst
238
241
  shared_test.rb # tests for lib/maf/shared/
239
242
  gemspec_test.rb # tests that the gem holds every runtime file
@@ -371,6 +374,7 @@ ruby test/context_watch_test.rb # covers the context-watch hook
371
374
  ruby test/worker_control_test.rb # covers maf worker
372
375
  ruby test/worker_list_test.rb # covers maf worker list
373
376
  ruby test/untrack_test.rb # covers maf untrack
377
+ ruby test/backup_test.rb # covers the backup and maf restore
374
378
  ruby test/analyst_test.rb # covers the analyst
375
379
  ruby test/shared_test.rb # covers lib/maf/shared/
376
380
  ```
data/assets/dispatcher CHANGED
@@ -175,6 +175,13 @@ module Dispatcher
175
175
  end
176
176
  end
177
177
 
178
+ def self.refuse_nested
179
+ return unless Maf::Shared::Sandbox.nested?
180
+
181
+ Log.line(Maf::Shared::Sandbox::NESTED.delete_prefix("maf: "))
182
+ exit 1
183
+ end
184
+
178
185
  # Project finds the main checkout from any worktree. Lead roles own no
179
186
  # tasks: they get an inbox prompt and no task polling.
180
187
  Project = Maf::Shared::Project
@@ -1512,5 +1519,6 @@ if __FILE__ == $PROGRAM_NAME
1512
1519
  ENV["PATH"] = [File.dirname(File.expand_path(__FILE__)), ENV["PATH"]].compact.join(File::PATH_SEPARATOR)
1513
1520
  # Each run inherits the git persona of the agents.
1514
1521
  Maf::Shared::GitIdentity.apply(Dispatcher::Project.manifest)
1522
+ Dispatcher.refuse_nested
1515
1523
  Dispatcher::Main.new(Dispatcher::Options.parse(ARGV)).run
1516
1524
  end
data/lib/maf/backup.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # backup.rb - copies of the flow config in the git directory, and maf restore.
4
+ require "fileutils"
5
+ require "open3"
6
+ require "time"
7
+
8
+ module Maf
9
+ # Backup keeps copies of the files in .maf/ that maf update cannot make again.
10
+ # Each copy is a folder in .git/maf/backup/, so no checkout, merge, or reset
11
+ # changes it. A copy is made only when config.json exists and the files differ
12
+ # from the newest copy.
13
+ class Backup
14
+ FILES = %w[config.json roles.yml workflow.md sandbox.json].freeze
15
+ KEEP = 10
16
+ MISSING = "maf: .maf/config.json is missing. A backup from %<time>s has it. Run: maf restore"
17
+
18
+ def initialize(root)
19
+ @root = root
20
+ @dir = git_dir&.then { |dir| File.join(dir, "maf", "backup") }
21
+ end
22
+
23
+ def save
24
+ files = current
25
+ return if @dir.nil? || !files.key?("config.json") || files == read(latest)
26
+
27
+ write(File.join(@dir, Time.now.utc.strftime("%Y%m%dT%H%M%SZ")), files)
28
+ prune
29
+ end
30
+
31
+ def snapshots = @dir ? Dir.glob(File.join(@dir, "*")).select { |path| File.directory?(path) }.sort : []
32
+ def latest = snapshots.last
33
+ def find(name) = snapshots.find { |path| File.basename(path) == name }
34
+ def read(dir) = dir ? FILES.to_h { |name| [name, file(dir, name)] }.compact : {}
35
+ def time(dir) = Time.strptime(File.basename(dir), "%Y%m%dT%H%M%S%z").localtime.strftime("%Y-%m-%d %H:%M")
36
+
37
+ def warn_missing
38
+ maf = File.join(@root, ".maf")
39
+ return if !Dir.exist?(maf) || File.exist?(File.join(maf, "config.json")) || !read(latest).key?("config.json")
40
+
41
+ warn format(MISSING, time: time(latest))
42
+ end
43
+
44
+ private
45
+
46
+ def current = read(File.join(@root, ".maf"))
47
+ def file(dir, name) = File.file?(File.join(dir, name)) ? File.read(File.join(dir, name)) : nil
48
+
49
+ def write(dir, files)
50
+ FileUtils.mkdir_p(dir)
51
+ files.each { |name, text| File.write(File.join(dir, name), text) }
52
+ end
53
+
54
+ def prune = snapshots[0...-KEEP].each { |dir| FileUtils.rm_rf(dir) }
55
+
56
+ def git_dir
57
+ out, status = Open3.capture2("git", "-C", @root, "rev-parse", "--git-common-dir", err: File::NULL)
58
+ status.success? ? File.expand_path(out.strip, @root) : nil
59
+ rescue Errno::ENOENT
60
+ nil
61
+ end
62
+ end
63
+ end
data/lib/maf/budget.rb CHANGED
@@ -44,6 +44,8 @@ module Maf
44
44
  if @max && count > @max
45
45
  end
46
46
 
47
+ def limited? = !@max.nil?
48
+
47
49
  def allowed?(harness, model)
48
50
  @allow.empty? || @allow.any? { |spec| [harness, "#{harness}:#{model}"].include?(spec) }
49
51
  end
data/lib/maf/cli.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "team_command"
14
14
  require_relative "worker_control"
15
15
  require_relative "worker_list"
16
16
  require_relative "untrack"
17
+ require_relative "restore"
17
18
  require_relative "vm"
18
19
  require_relative "version"
19
20
 
@@ -59,6 +60,7 @@ module Maf
59
60
  "uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
60
61
  "migrate" => "[--check] [--yes] move an old-layout install into .maf/",
61
62
  "untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
63
+ "restore" => "[--list] [BACKUP] [--force] put the flow config back from a backup in .git/maf/backup",
62
64
  "vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
63
65
  "menu" => " interactive mode (also: maf without a command)",
64
66
  "guide" => " print the setup guide for an AI agent (install.md)",
@@ -76,11 +78,26 @@ module Maf
76
78
  abort "maf: unknown command '#{@command}'. Run: maf help" unless COMMANDS.key?(@command)
77
79
  abort "maf: #{Migrate::HINT}" if old_layout?
78
80
 
79
- send("run_#{@command}")
81
+ backed_up { send("run_#{@command}") }
80
82
  end
81
83
 
84
+ NO_BACKUP = %w[version guide roles].freeze
85
+
82
86
  private
83
87
 
88
+ def backed_up
89
+ backup = checked_backup unless NO_BACKUP.include?(@command)
90
+ yield
91
+ ensure
92
+ backup&.save
93
+ end
94
+
95
+ def checked_backup
96
+ backup = Backup.new(SetupAgent::Project.root)
97
+ backup.warn_missing unless @command == "restore"
98
+ backup.tap(&:save)
99
+ end
100
+
84
101
  # Every command except these needs the new layout.
85
102
  # maf uninstall --check previews the migration on an old layout.
86
103
  def old_layout?
@@ -132,6 +149,7 @@ module Maf
132
149
  def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
133
150
  def run_version = puts("maf #{VERSION}")
134
151
  def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
152
+ def run_restore = Restore.new(SetupAgent::Project.root, @args).run
135
153
  def run_migrate = Migrate::Runner.new(["--project", Dir.pwd, *@args]).run
136
154
 
137
155
  def run_agents
@@ -212,13 +212,16 @@ module Maf
212
212
  # run the team. Dispatched workers cost no tokens while idle, so the rules
213
213
  # scale on backlog, not on cost.
214
214
  TEAM_RULES = <<~TEXT.strip
215
- - If the user gives you a team budget, record it: `maf team set --max <n> --allow <harness[:model]> ...`.
216
- Then manage the team yourself in dispatch mode. `maf prepare ... --dispatch` starts the worker in the background.
215
+ - Only the user sets the team budget: `maf team set --max <n> --allow <harness[:model]> ...`.
216
+ If the user gives you a budget in words, give the user that command.
217
+ With a budget, manage the team yourself in dispatch mode. `maf prepare ... --dispatch` starts the worker in the background.
218
+ In the macOS sandbox, maf cannot start it. Give the user the two commands that it prints.
217
219
  - Check the team with `maf team`. It shows the budget, each worker, and the tasks by role.
218
220
  - Staff the architect first, with `--dispatch`. A goal needs the architect before any other worker.
219
221
  A dispatched architect starts on each message. An idle interactive architect reads nothing.
220
222
  - Check who runs with `coord who`. A role without a live worker does not read its messages.
221
- - If coord reports "No worker runs role <role>", add a worker for that role.
223
+ - If coord reports "No worker runs role <role>", add a worker for that role if the budget has a free slot.
224
+ Without a budget, tell the user.
222
225
  - If a role has more than three backlog tasks and the budget has a free slot, add a worker for that role.
223
226
  - If a role has no tasks and no open goal needs it, retire its extra workers.
224
227
  Keep one worker per role that an open goal needs.
@@ -242,7 +245,8 @@ module Maf
242
245
 
243
246
  Rules:
244
247
  - Never edit source files. Never create tasks; only the architect creates tasks.
245
- - Change the team when the user asks. Do not ask the user to run setup steps.
248
+ - Change the team only when the user asks, or within the team budget. Without a budget, `maf prepare` refuses.
249
+ Then give the user the command, or ask the user for a budget.
246
250
  Add a worker: `maf prepare <harness> <role>_<n>`.
247
251
  Replace a worker: `maf prepare <harness> <role>_<n> --replace <old-role>_<n>`.
248
252
  Remove a worker: `maf retire <role>_<n>`.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # restore.rb - maf restore: put the flow config back from a backup.
4
+ require "fileutils"
5
+ require_relative "backup"
6
+
7
+ module Maf
8
+ # Restore copies the files of a backup into .maf/. It keeps a file that
9
+ # exists, unless --force is given. Then it runs maf update.
10
+ class Restore
11
+ USAGE = "usage: maf restore [--list] [BACKUP] [--force]"
12
+
13
+ def initialize(root, argv)
14
+ @root = root
15
+ @backup = Backup.new(root)
16
+ @list = argv.include?("--list")
17
+ @force = argv.include?("--force")
18
+ @name = argv.find { |arg| !arg.start_with?("-") }
19
+ end
20
+
21
+ def run
22
+ return list if @list
23
+
24
+ dir = @name ? @backup.find(@name) : @backup.latest
25
+ abort(@name ? "maf: no backup #{@name}. Run: maf restore --list" : "maf: no backup in #{@root}") unless dir
26
+ restore(dir)
27
+ end
28
+
29
+ private
30
+
31
+ def list
32
+ return puts("No backup yet. maf makes one when a maf command runs.") if @backup.snapshots.empty?
33
+
34
+ @backup.snapshots.reverse_each { |dir| puts line(dir) }
35
+ end
36
+
37
+ def line(dir)
38
+ files = @backup.read(dir)
39
+ agents = JSON.parse(files.fetch("config.json", "{}")).fetch("agents", []).size
40
+ "#{File.basename(dir)} #{@backup.time(dir)} #{agents} agents #{files.keys.join(", ")}"
41
+ rescue JSON::ParserError
42
+ "#{File.basename(dir)} #{@backup.time(dir)} #{files.keys.join(", ")}"
43
+ end
44
+
45
+ def restore(dir)
46
+ puts "Backup #{File.basename(dir)} (#{@backup.time(dir)}):"
47
+ written = @backup.read(dir).select { |name, text| put(name, text) }
48
+ return puts("Nothing to restore. --force replaces the files that exist.") if written.empty?
49
+
50
+ Dir.chdir(@root) { Maf.flow } if written.key?("config.json")
51
+ end
52
+
53
+ def put(name, text)
54
+ path = File.join(@root, ".maf", name)
55
+ return puts(" keep .maf/#{name} (exists; --force replaces it)") || false if File.exist?(path) && !@force
56
+
57
+ FileUtils.mkdir_p(File.dirname(path))
58
+ File.write(path, text)
59
+ puts(" restore .maf/#{name}") || true
60
+ end
61
+ end
62
+ end
@@ -11,6 +11,7 @@ module Maf
11
11
  # Seatbelt runs a command in a macOS sandbox-exec profile.
12
12
  module Seatbelt
13
13
  COMMAND = "/usr/bin/sandbox-exec"
14
+ MARKER = "MAF_SEATBELT"
14
15
  DEVICES = '(literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") ' \
15
16
  '(regex #"^/dev/tty") (regex #"^/dev/fd/") (regex #"^/dev/ptmx")'
16
17
  DNS = '(path-literal "/private/var/run/mDNSResponder")'
@@ -20,9 +21,12 @@ module Maf
20
21
 
21
22
  def self.wrap(cmd, paths)
22
23
  tmp = private_tmp(paths.root)
23
- [COMMAND, "-p", profile(paths, tmp), "env", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache", *cmd]
24
+ [COMMAND, "-p", profile(paths, tmp), "env", "#{MARKER}=1", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache",
25
+ *cmd]
24
26
  end
25
27
 
28
+ def self.inside? = ENV[MARKER] == "1"
29
+
26
30
  def self.available? = File.executable?(COMMAND)
27
31
  def self.require! = available? || abort("maf: sandbox mode seatbelt needs #{COMMAND} (macOS only).")
28
32
 
@@ -18,6 +18,8 @@ module Maf
18
18
  BACKENDS = { "seatbelt" => Seatbelt, "bwrap" => Bwrap }.freeze
19
19
  MODES = ["off", "auto", *BACKENDS.keys].freeze
20
20
  POLICY = File.join(".maf", "sandbox.json")
21
+ NESTED = "maf: this process runs in the macOS sandbox, and macOS cannot start a second sandbox in it. " \
22
+ "Start the worker from your terminal: cd <worktree> && maf start"
21
23
 
22
24
  def self.policy
23
25
  path = File.join(Project.root, POLICY)
@@ -40,9 +42,13 @@ module Maf
40
42
  def self.wrap(cmd, harness, coord_dir: ENV.fetch("COORD_DIR", nil))
41
43
  policy = self.policy
42
44
  mode = mode(policy)
45
+ refuse_nested!(mode)
43
46
  mode == "off" ? cmd : BACKENDS[mode].wrap(harness.sandboxed(cmd), paths(harness, policy, coord_dir))
44
47
  end
45
48
 
49
+ def self.nested?(mode = self.mode) = mode == "seatbelt" && Seatbelt.inside?
50
+ def self.refuse_nested!(mode = self.mode) = nested?(mode) && abort(NESTED)
51
+
46
52
  def self.keeping_links(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
47
53
  links = protected_links(harness, coord_dir)
48
54
  yield
data/lib/maf/team.rb CHANGED
@@ -24,6 +24,7 @@ module Maf
24
24
  end
25
25
 
26
26
  def self.notify(root, text) = coord(root, "msg", "--from", "maf", "architect", text)
27
+ def self.agent? = !ENV["COORD_ROLE"].to_s.empty?
27
28
  end
28
29
 
29
30
  # Prepare makes a worker ready to start. With --replace, it retires the
@@ -35,6 +36,8 @@ module Maf
35
36
  # The architect never talks to the user. A dispatched architect gets fresh
36
37
  # sessions with a handoff note, so its context stays small.
37
38
  DISPATCH_DEFAULT = %w[architect].freeze
39
+ NO_BUDGET = "maf: an agent cannot change the team without a budget. Ask the user to set one " \
40
+ "(maf team set --max N), or to run this command in a terminal."
38
41
 
39
42
  def self.mode_args(argv)
40
43
  return argv - ["--interactive"] if argv.include?("--interactive")
@@ -52,7 +55,7 @@ module Maf
52
55
  end
53
56
 
54
57
  def run
55
- @budget.check!(@args.harness, @args.model, @budget.count(workers_after))
58
+ check_budget
56
59
  retire = (Retire.new(@root, @replace).tap(&:check!) if @replace)
57
60
  Dir.chdir(@root) { prepare }
58
61
  retire.run if retire && Workers.id(@replace) != @args.worker
@@ -61,11 +64,16 @@ module Maf
61
64
 
62
65
  def announce
63
66
  Team.notify(@root, "Team change: worker #{@args.worker} (#{@args.harness}) joined as #{@args.role}.")
64
- @args.dispatch ? start_dispatcher : print_next_steps
67
+ @args.dispatch && !Shared::Sandbox.nested? ? start_dispatcher : print_next_steps
65
68
  end
66
69
 
67
70
  private
68
71
 
72
+ def check_budget
73
+ abort NO_BUDGET if Team.agent? && !@budget.limited?
74
+ @budget.check!(@args.harness, @args.model, @budget.count(workers_after))
75
+ end
76
+
69
77
  def take_replace(argv)
70
78
  index = argv.index("--replace") or return nil
71
79
  argv.slice!(index, 2)[1] || abort("maf: --replace needs a worker, for example backend-developer_2")
@@ -18,7 +18,14 @@ module Maf
18
18
  @root = root
19
19
  end
20
20
 
21
- def run = @argv.first == "set" ? set(@argv.drop(1)) : show
21
+ AGENT_SET = "maf: only the user sets the team budget. Give the user this command to run in a terminal."
22
+
23
+ def run
24
+ return show unless @argv.first == "set"
25
+
26
+ abort AGENT_SET if Team.agent?
27
+ set(@argv.drop(1))
28
+ end
22
29
 
23
30
  private
24
31
 
data/lib/maf/untrack.rb CHANGED
@@ -23,6 +23,10 @@ module Maf
23
23
 
24
24
  def self.warn_tracked(root) = (warn TRACKED unless new(root, []).tracked.empty?)
25
25
 
26
+ DONE = ["", "The flow left git. Review the change with `git status`, then commit it.",
27
+ "A branch that still tracks the flow files can delete them in a checkout or a merge.",
28
+ "maf keeps a backup of the flow config in .git/maf/backup. Run `maf restore` to put it back."].freeze
29
+
26
30
  def run
27
31
  steps = plan
28
32
  return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
@@ -55,7 +59,7 @@ module Maf
55
59
  abort "maf: cancelled" unless @yes || confirmed?
56
60
  steps.each(&:run)
57
61
  Dir.chdir(@root) { Maf.flow }
58
- puts "", "The flow left git. Review the change with `git status`, then commit it."
62
+ puts DONE
59
63
  end
60
64
 
61
65
  def confirmed?
data/lib/maf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maf
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dominik Alberski
@@ -59,6 +59,7 @@ files:
59
59
  - exe/maf
60
60
  - install.md
61
61
  - lib/maf.rb
62
+ - lib/maf/backup.rb
62
63
  - lib/maf/bootstrap.rb
63
64
  - lib/maf/bootstrap/claude_settings.rb
64
65
  - lib/maf/bootstrap/dependencies.rb
@@ -115,6 +116,7 @@ files:
115
116
  - lib/maf/migrate/runner.rb
116
117
  - lib/maf/migrate/worktrees.rb
117
118
  - lib/maf/prompt.rb
119
+ - lib/maf/restore.rb
118
120
  - lib/maf/retire.rb
119
121
  - lib/maf/role_limits.rb
120
122
  - lib/maf/setup_agent.rb