maf 0.3.0 → 0.5.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.
data/assets/steward ADDED
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # steward - run the host operations that the leads request.
5
+ #
6
+ # Usage: steward [start|stop|status|daemon]
7
+ # steward # start the daemon; no-op if it already runs
8
+ # steward stop
9
+ # steward status
10
+ #
11
+ # An agent runs `coord op request OPERATION [ARGUMENT]`. coord writes the
12
+ # request to .maf/coordination/ops/requests/. The daemon runs only the
13
+ # operations in Steward::Ops::TABLE, outside the sandbox, and sends the
14
+ # result to the inbox of the role that asked.
15
+ require "json"
16
+ require "fileutils"
17
+ require "rbconfig"
18
+ require "time"
19
+ begin
20
+ require_relative "../lib/maf/shared/project"
21
+ require_relative "../lib/maf/shared/processes"
22
+ require_relative "../lib/maf/shared/sandbox"
23
+ rescue LoadError
24
+ abort "steward: the maf shared library is missing. Run maf update."
25
+ end
26
+
27
+ # Steward runs the operations of Steward::Ops::TABLE for the leads.
28
+ module Steward
29
+ COORD_DIR = File.join(".maf", "coordination")
30
+ OPS_DIR = File.join(COORD_DIR, "ops")
31
+ PID_FILE = File.join(COORD_DIR, "steward.pid")
32
+ LOG_FILE = File.join(COORD_DIR, "steward.log")
33
+ POLL = 3
34
+
35
+ COMMANDS = { "stop" => :stop, "status" => :status, "daemon" => :loop_forever }.freeze
36
+
37
+ def self.run(argv)
38
+ Dir.chdir(Maf::Shared::Project.root)
39
+ return Approvals.run(argv) if Approvals::COMMANDS.include?(argv.first)
40
+
41
+ Daemon.public_send(COMMANDS.fetch(argv.first, :start))
42
+ end
43
+
44
+ def self.coord = [RbConfig.ruby, File.join(".maf", "bin", "coord")]
45
+
46
+ # Daemon starts, stops, and runs the poll loop. It exits when the pid file is gone.
47
+ module Daemon
48
+ NESTED = "steward: this process runs in the sandbox. Start steward from a terminal."
49
+ LOG = { out: LOG_FILE, err: LOG_FILE, pgroup: true }.freeze
50
+
51
+ def self.start
52
+ abort NESTED if inside_sandbox?
53
+ return puts("steward: already running (pid #{Pid.read})") if Pid.running?
54
+
55
+ spawn_daemon
56
+ end
57
+
58
+ def self.inside_sandbox? = Maf::Shared::Sandbox::Seatbelt.inside? || ENV["MAF_BWRAP"] == "1"
59
+
60
+ def self.spawn_daemon
61
+ FileUtils.mkdir_p(COORD_DIR)
62
+ pid = Process.spawn(RbConfig.ruby, File.expand_path(__FILE__), "daemon", **LOG)
63
+ Process.detach(pid) && Pid.write(pid)
64
+ puts "steward: started (pid #{pid}), log at #{LOG_FILE}"
65
+ end
66
+
67
+ def self.stop
68
+ return puts("steward: not running") unless Pid.running?
69
+
70
+ Process.kill("TERM", Pid.read)
71
+ Pid.clear
72
+ puts "steward: stopped"
73
+ end
74
+
75
+ def self.status = puts(Pid.running? ? "steward: running (pid #{Pid.read})" : "steward: not running")
76
+
77
+ def self.loop_forever
78
+ Pid.write(Process.pid)
79
+ Signal.trap("TERM") { exit }
80
+ tick while File.exist?(PID_FILE)
81
+ end
82
+
83
+ def self.tick
84
+ Queue.new(OPS_DIR).each_request { |request| Handler.new(request).run }
85
+ sleep POLL
86
+ end
87
+ end
88
+
89
+ # Pid reads and writes the pid file. A non-positive pid is not a process.
90
+ module Pid
91
+ def self.running? = read.positive? && Maf::Shared::Processes.alive?(read)
92
+ def self.read = File.exist?(PID_FILE) ? File.read(PID_FILE).to_i : 0
93
+ def self.write(pid) = File.write(PID_FILE, pid)
94
+ def self.clear = FileUtils.rm_f(PID_FILE)
95
+ end
96
+
97
+ # Queue moves each request file to done/ before the handler runs it, so a
98
+ # restart of the daemon never runs a request twice.
99
+ class Queue
100
+ def initialize(dir)
101
+ @dir = dir
102
+ end
103
+
104
+ def each_request
105
+ Dir.glob(File.join(@dir, "requests", "*.json")).each do |path|
106
+ request = take(path)
107
+ yield request if request
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def take(path)
114
+ done = done_path(path)
115
+ File.rename(path, done)
116
+ JSON.parse(File.read(done)).merge("path" => done)
117
+ rescue JSON::ParserError, SystemCallError
118
+ nil
119
+ end
120
+
121
+ def done_path(path)
122
+ File.join(@dir, "done", File.basename(path)).tap { |done| FileUtils.mkdir_p(File.dirname(done)) }
123
+ end
124
+ end
125
+
126
+ # Ops holds the fixed list of operations. Each one maps validated
127
+ # arguments to an argument list. No request text reaches a shell.
128
+ module Ops
129
+ TABLE = {
130
+ "worker-restart" => { args: %i[worker], level: "auto", argv: ->(w) { ["maf", "worker", "restart", w["id"]] } },
131
+ "worker-retire" => { args: %i[worker], level: "auto", argv: ->(w) { ["maf", "retire", w["id"]] } },
132
+ "worktree-sync" => { args: %i[worker], level: "auto",
133
+ argv: ->(w) { [*Steward.coord, "worktree", w["role"], w["worker_id"]] } },
134
+ "goal-sync" => { args: %i[goal], level: "auto", argv: ->(g) { [*Steward.coord, "goal", "sync", g] } },
135
+ "runtime-restore" => { args: [], level: "auto", argv: -> { %w[maf update] } },
136
+ "worker-start" => { args: %i[worker], level: "approve", argv: ->(w) { ["maf", "worker", "start", w["id"]] } },
137
+ "worker-model" => { args: %i[worker model], level: "approve", argv: ->(w, m) { Ops.replace(w, m) } },
138
+ "bundle-install" => { args: [], level: "approve",
139
+ argv: -> { Validate.lockfile && [{ "BUNDLE_FROZEN" => "true" }, "bundle", "install"] } }
140
+ }.freeze
141
+
142
+ def self.spec(name) = TABLE.fetch(name) { raise Refused, "unknown operation #{name}. Known: #{known}" }
143
+ def self.known = TABLE.keys.join(" ")
144
+ def self.level(name) = Levels.read.fetch(name, spec(name)[:level])
145
+
146
+ def self.argv(name, args)
147
+ kinds = spec(name)[:args]
148
+ raise Refused, "#{name} takes #{kinds.size} argument(s): #{kinds.join(" ")}" unless args.size == kinds.size
149
+
150
+ spec(name)[:argv].call(*kinds.zip(args).map { |kind, arg| Validate.public_send(kind, arg) })
151
+ end
152
+
153
+ def self.replace(worker, model)
154
+ id = "#{worker["role"]}_#{worker["worker_id"]}"
155
+ ["maf", "prepare", worker["harness"], id, "--model", model, "--replace", id,
156
+ worker["dispatch"] ? "--dispatch" : "--interactive"]
157
+ end
158
+ end
159
+
160
+ # Levels reads the key ops of .maf/sandbox.json. The sandbox protects that file.
161
+ module Levels
162
+ VALUES = %w[auto approve off].freeze
163
+
164
+ def self.read
165
+ data = JSON.parse(File.read(File.join(".maf", "sandbox.json"))).fetch("ops", {})
166
+ data.is_a?(Hash) ? data.select { |_, level| VALUES.include?(level) } : {}
167
+ rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
168
+ {}
169
+ end
170
+ end
171
+
172
+ class Refused < StandardError; end
173
+
174
+ # Validate checks each argument against the project state, not against the request.
175
+ module Validate
176
+ WORKTREES = File.join(".maf", "worktrees")
177
+
178
+ def self.model(arg)
179
+ return arg if arg.to_s.match?(%r{\A[A-Za-z0-9][A-Za-z0-9._:/@-]{0,99}\z})
180
+
181
+ raise Refused, "a model is letters, digits, and . _ : / @ -"
182
+ end
183
+
184
+ def self.lockfile = File.exist?("Gemfile.lock") || raise(Refused, "the project has no Gemfile.lock")
185
+
186
+ def self.worker(arg)
187
+ entry = workers.fetch(arg.to_s) { raise Refused, "no worker #{arg}" }
188
+ raise Refused, "worker #{arg} is outside #{WORKTREES}" unless inside_worktrees?(entry["dir"].to_s)
189
+
190
+ entry.merge("id" => arg.to_s)
191
+ end
192
+
193
+ def self.goal(arg)
194
+ raise Refused, "a goal id is 8 hex characters" unless arg.to_s.match?(/\A\h{8}\z/)
195
+ raise Refused, "no goal branch goal/#{arg}" unless system("git", "rev-parse", "-q", "--verify",
196
+ "refs/heads/goal/#{arg}", out: File::NULL)
197
+
198
+ arg
199
+ end
200
+
201
+ def self.workers
202
+ JSON.parse(File.read(File.join(COORD_DIR, "workers.json")))
203
+ rescue JSON::ParserError, SystemCallError
204
+ {}
205
+ end
206
+
207
+ def self.inside_worktrees?(dir)
208
+ root = File.realpath(WORKTREES)
209
+ File.realpath(dir).start_with?("#{root}/")
210
+ rescue SystemCallError
211
+ false
212
+ end
213
+ end
214
+
215
+ # Limit allows each operation 3 runs per hour for one argument. A loop of
216
+ # restarts then stops, and the project manager hears about it.
217
+ class Limit
218
+ MAX = 3
219
+ WINDOW = 3600
220
+
221
+ def initialize(path) = @path = path
222
+
223
+ def allow?(key) = recent.count(key) < MAX
224
+ def record(key) = File.open(@path, "a") { |file| file.puts(JSON.generate("at" => Time.now.to_i, "key" => key)) }
225
+
226
+ private
227
+
228
+ def recent
229
+ return [] unless File.exist?(@path)
230
+
231
+ File.readlines(@path).filter_map { |line| entry(line) }
232
+ end
233
+
234
+ def entry(line)
235
+ data = JSON.parse(line)
236
+ data["key"] if Time.now.to_i - data["at"].to_i < WINDOW
237
+ rescue JSON::ParserError
238
+ nil
239
+ end
240
+ end
241
+
242
+ # Handler runs one request and reports the result to the role that asked.
243
+ class Handler
244
+ TIMEOUT = 2000
245
+
246
+ def initialize(request, runner: Runner, limit: Limit.new(File.join(OPS_DIR, "history.jsonl")), approved: false)
247
+ @request = request
248
+ @runner = runner
249
+ @limit = limit
250
+ @approved = approved
251
+ end
252
+
253
+ def run
254
+ route(Ops.argv(name, args), Ops.level(name))
255
+ rescue Refused => e
256
+ report(false, "refused: #{e.message}")
257
+ end
258
+
259
+ def route(argv, level)
260
+ raise Refused, "#{name} is off in .maf/sandbox.json" if level == "off"
261
+ return park if level == "approve" && !@approved
262
+
263
+ execute(argv)
264
+ end
265
+
266
+ def execute(argv)
267
+ return over_limit unless @limit.allow?(key)
268
+
269
+ @limit.record(key)
270
+ report(*@runner.call(argv, TIMEOUT))
271
+ end
272
+
273
+ private
274
+
275
+ def name = @request["op"].to_s
276
+ def args = Array(@request["args"]).map(&:to_s)
277
+ def key = [name, *args].join(" ")
278
+
279
+ def park
280
+ File.rename(@request["path"], Approvals.pending_path(@request["id"]))
281
+ text = "op #{@request["id"]} #{key}: waits for the user. The user runs: maf op approve #{@request["id"]}"
282
+ Reply.event(text)
283
+ [@request["role"].to_s, "project-manager"].uniq.each { |role| Reply.deliver(role, text) }
284
+ end
285
+
286
+ def over_limit
287
+ report(false, "refused: #{key} ran #{Limit::MAX} times in the last hour")
288
+ Reply.deliver("project-manager", "ESCALATION from steward: #{key} ran #{Limit::MAX} times in an hour. " \
289
+ "Check the cause before a new request.")
290
+ end
291
+
292
+ def report(success, output)
293
+ text = "op #{@request["id"]} #{key}: #{success ? "done" : "failed"}. #{output.to_s.lines.last(5).join.strip}"
294
+ File.write(@request["path"], JSON.generate(@request.merge("ok" => success, "output" => output.to_s)))
295
+ Reply.event(text)
296
+ Reply.deliver(@request["role"].to_s, text)
297
+ end
298
+ end
299
+
300
+ # Approvals lists, approves, and denies the waiting requests. The user runs
301
+ # them through maf op, outside the sandbox.
302
+ module Approvals
303
+ COMMANDS = %w[list approve deny].freeze
304
+ ID = /\A\d{8}T\d{6}Z-\h{6}\z/
305
+ REFUSED = "steward: only the user approves, from a terminal outside the sandbox."
306
+
307
+ def self.run(argv)
308
+ abort REFUSED if Daemon.inside_sandbox? || !ENV["COORD_ROLE"].to_s.empty?
309
+ argv.first == "list" ? list : public_send(argv.first, argv[1].to_s)
310
+ rescue Refused => e
311
+ abort "steward: #{e.message}"
312
+ end
313
+
314
+ def self.list
315
+ files = Dir.glob(File.join(OPS_DIR, "pending", "*.json"))
316
+ puts "No request waits." if files.empty?
317
+ files.each { |path| puts line(JSON.parse(File.read(path))) }
318
+ end
319
+
320
+ def self.line(req) = [req["id"], [req["op"], *req["args"]].join(" "), "from #{req["worker"]}"].join(" ")
321
+
322
+ def self.approve(id)
323
+ done = File.join(OPS_DIR, "done", "#{id}.json")
324
+ File.rename(waiting(id), done)
325
+ Handler.new(JSON.parse(File.read(done)).merge("path" => done), approved: true).run
326
+ puts File.read(done)
327
+ end
328
+
329
+ def self.deny(id)
330
+ request = JSON.parse(File.read(waiting(id)))
331
+ File.delete(waiting(id))
332
+ notify(request, "op #{id} #{[request["op"], *request["args"]].join(" ")}: denied by the user.")
333
+ end
334
+
335
+ def self.notify(request, text)
336
+ Reply.event(text)
337
+ Reply.deliver(request["role"].to_s, text)
338
+ puts text
339
+ end
340
+
341
+ def self.waiting(id) = pending_path(id).tap { |path| File.exist?(path) || raise(Refused, "no request #{id}") }
342
+
343
+ def self.pending_path(id)
344
+ raise Refused, "a request id looks like 20261021T120000Z-a1b2c3" unless id.to_s.match?(ID)
345
+
346
+ File.join(OPS_DIR, "pending", "#{id}.json").tap { |path| FileUtils.mkdir_p(File.dirname(path)) }
347
+ end
348
+ end
349
+
350
+ # Runner runs an argument list without a shell and stops it at the timeout.
351
+ module Runner
352
+ def self.call(argv, timeout)
353
+ reader, writer = IO.pipe
354
+ pid = start(argv, writer)
355
+ writer.close
356
+ pid ? collect(pid, reader, timeout) : [false, "cannot start #{argv.first}"]
357
+ end
358
+
359
+ def self.start(argv, writer)
360
+ Process.spawn(*argv, out: writer, err: writer, pgroup: true)
361
+ rescue SystemCallError
362
+ nil
363
+ end
364
+
365
+ def self.collect(pid, reader, timeout)
366
+ output = Thread.new { reader.read }
367
+ [succeeded?(pid, timeout), output.value]
368
+ end
369
+
370
+ def self.succeeded?(pid, timeout)
371
+ deadline = Time.now + timeout
372
+ sleep 0.2 until (status = Process.waitpid2(pid, Process::WNOHANG)&.last) || Time.now > deadline
373
+ kill(pid) unless status
374
+ status&.success? || false
375
+ end
376
+
377
+ def self.kill(pid)
378
+ Process.kill("KILL", -pid)
379
+ Process.wait(pid)
380
+ end
381
+ end
382
+
383
+ # Reply writes the event log line and the inbox message.
384
+ module Reply
385
+ def self.event(text)
386
+ line = [Time.now.utc.iso8601, "op", "steward", text.tr("\t\n", " ")].join("\t")
387
+ File.open(File.join(COORD_DIR, "events.log"), "a") { |file| file.puts(line) }
388
+ end
389
+
390
+ def self.deliver(role, text)
391
+ return if role.empty?
392
+
393
+ system(*Steward.coord, "msg", "--from", "steward", role, text, out: File::NULL, err: File::NULL)
394
+ end
395
+ end
396
+ end
397
+
398
+ Steward.run(ARGV) if __FILE__ == $PROGRAM_NAME
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
@@ -8,8 +8,8 @@ module Maf
8
8
  # Each step is [planner, method]. The order is the order of the output.
9
9
  PLAN_STEPS = [
10
10
  %i[layout dirs], %i[scripts shared], %i[scripts coord], %i[scripts dispatcher], %i[scripts vault],
11
- %i[scripts dashboard], %i[scripts analyst], %i[scripts env], %i[text taskrc], %i[text exclude],
12
- %i[scripts hooks], %i[scripts opencode_plugin], %i[claude plan], %i[git commit_guard],
11
+ %i[scripts steward], %i[scripts dashboard], %i[scripts analyst], %i[scripts env], %i[text taskrc],
12
+ %i[text exclude], %i[scripts hooks], %i[scripts opencode_plugin], %i[claude plan], %i[git commit_guard],
13
13
  %i[scripts doc_graph], %i[git doc_graph_hooks]
14
14
  ].freeze
15
15
  PLANNERS = { layout: LayoutPlanner, scripts: ScriptPlanner, text: TextPlanner, claude: ClaudeSettings,
@@ -46,6 +46,10 @@ module Maf
46
46
  script("vault", VAULT_SIGNATURE)
47
47
  end
48
48
 
49
+ def steward
50
+ script("steward", STEWARD_SIGNATURE)
51
+ end
52
+
49
53
  # env.sh puts .maf/bin on PATH. A worker runs `source .maf/env.sh`.
50
54
  def env
51
55
  script("env.sh", ENV_SIGNATURE, @project.path(MAF_DIR, "env.sh"))
data/lib/maf/bootstrap.rb CHANGED
@@ -29,6 +29,7 @@ module Maf
29
29
  SETUP_AGENT_SIGNATURE = "setup_agent - create a worktree for one agent and launch its harness session."
30
30
  DISPATCHER_SIGNATURE = "dispatcher - task board and inbox monitor that starts one-shot agents."
31
31
  VAULT_SIGNATURE = "vault - shared knowledge base watcher (graphify + Obsidian + MCP)."
32
+ STEWARD_SIGNATURE = "steward - run the host operations that the leads request."
32
33
  DASHBOARD_SIGNATURE = "dashboard - local observability web UI for multi-agent coordination."
33
34
  DASHBOARD_PAGE_SIGNATURE = "dashboard.html - page of the maf dashboard."
34
35
  ANALYST_SIGNATURE = "analyst - ask a small model for token hints about one dispatched worker."
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
@@ -11,9 +11,11 @@ require_relative "setup_agent"
11
11
  require_relative "menu"
12
12
  require_relative "team"
13
13
  require_relative "team_command"
14
+ require_relative "op_command"
14
15
  require_relative "worker_control"
15
16
  require_relative "worker_list"
16
17
  require_relative "untrack"
18
+ require_relative "restore"
17
19
  require_relative "vm"
18
20
  require_relative "version"
19
21
 
@@ -56,9 +58,11 @@ module Maf
56
58
  "[--max-session-runs N] [--cache-window S] list the running workers, or control one worker; " \
57
59
  "start and restart save the session limits of the role",
58
60
  "team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
61
+ "op" => "list | approve ID | deny ID the host operations that wait for you (steward)",
59
62
  "uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
60
63
  "migrate" => "[--check] [--yes] move an old-layout install into .maf/",
61
64
  "untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
65
+ "restore" => "[--list] [BACKUP] [--force] put the flow config back from a backup in .git/maf/backup",
62
66
  "vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
63
67
  "menu" => " interactive mode (also: maf without a command)",
64
68
  "guide" => " print the setup guide for an AI agent (install.md)",
@@ -76,11 +80,26 @@ module Maf
76
80
  abort "maf: unknown command '#{@command}'. Run: maf help" unless COMMANDS.key?(@command)
77
81
  abort "maf: #{Migrate::HINT}" if old_layout?
78
82
 
79
- send("run_#{@command}")
83
+ backed_up { send("run_#{@command}") }
80
84
  end
81
85
 
86
+ NO_BACKUP = %w[version guide roles].freeze
87
+
82
88
  private
83
89
 
90
+ def backed_up
91
+ backup = checked_backup unless NO_BACKUP.include?(@command)
92
+ yield
93
+ ensure
94
+ backup&.save
95
+ end
96
+
97
+ def checked_backup
98
+ backup = Backup.new(SetupAgent::Project.root)
99
+ backup.warn_missing unless @command == "restore"
100
+ backup.tap(&:save)
101
+ end
102
+
84
103
  # Every command except these needs the new layout.
85
104
  # maf uninstall --check previews the migration on an old layout.
86
105
  def old_layout?
@@ -101,7 +120,7 @@ module Maf
101
120
  def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
102
121
  def run_update
103
122
  Maf.flow(*@args)
104
- Untrack.warn_tracked(SetupAgent::Project.root)
123
+ Untrack.after_update(SetupAgent::Project.root)
105
124
  end
106
125
  def run_roles = Maf.flow("--list-roles")
107
126
 
@@ -114,17 +133,16 @@ module Maf
114
133
  def run_start = @args.empty? ? SetupAgent.run_here : SetupAgent.run(@args)
115
134
  def run_prepare = Prepare.new(@args).run
116
135
  def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
136
+ def run_op = OpCommand.new(@args, SetupAgent::Project.root).run
117
137
  def run_worker
118
138
  return WorkerList.new(SetupAgent::Project.root).run if @args.first == "list"
119
139
 
120
- spec = @args[1] || abort(worker_usage)
140
+ spec = @args[1] || abort(WorkerControl::USAGE)
121
141
  limits = RoleLimits.parse(@args)
122
142
  WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
123
143
  .run(@args[0].to_s)
124
144
  end
125
145
 
126
- def worker_usage = "usage: maf worker list | #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
127
- "[--max-context N] [--max-session-runs N] [--cache-window S]"
128
146
  def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
129
147
  def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
130
148
  def run_menu = Menu.new.run
@@ -132,6 +150,7 @@ module Maf
132
150
  def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
133
151
  def run_version = puts("maf #{VERSION}")
134
152
  def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
153
+ def run_restore = Restore.new(SetupAgent::Project.root, @args).run
135
154
  def run_migrate = Migrate::Runner.new(["--project", Dir.pwd, *@args]).run
136
155
 
137
156
  def run_agents
@@ -149,6 +149,18 @@ module Maf
149
149
  A goal pull request uses a merge commit, never a squash.
150
150
  TEXT
151
151
 
152
+ HOST_OPS_RULE = <<~TEXT.strip
153
+ - The sandbox blocks some repairs. Request them from steward: `coord op request <operation> [<argument>]`.
154
+ `worker-restart <worker>`, `worker-retire <worker>`: a worker that is stuck or not seen.
155
+ `worktree-sync <worker>`: a worker branch behind the base branch, or flow files that block it.
156
+ `goal-sync <goal-short-id>`: `coord goal sync` fails with "Operation not permitted".
157
+ `runtime-restore`: the files of `.maf/` are missing in a worktree.
158
+ These wait for the user: `worker-start <worker>`, `worker-model <worker> <model>`,
159
+ `bundle-install` (gems are missing after a Gemfile.lock change).
160
+ steward sends the result to your inbox. Tell the user only if steward refuses or the operation fails.
161
+ If an operation waits, tell the user the `maf op approve <id>` command once. Do not request it again.
162
+ TEXT
163
+
152
164
  ARCHITECT_RULES = <<~TEXT.strip
153
165
  - Never edit files directly. Dispatch work.
154
166
  - Change a goal branch only with `coord land` and `coord goal sync`. Never run `git merge` or `git commit` on it.
@@ -164,6 +176,7 @@ module Maf
164
176
  - Before you revise an artifact that a reviewer read, copy it to `<name>.r<round>.md`. Name both files
165
177
  in the review task. The reviewer reads `diff -u` of the two files and checks the open findings only.
166
178
  #{GRAPH_RULE}
179
+ #{HOST_OPS_RULE}
167
180
  TEXT
168
181
 
169
182
  # The architect takes goals from the project manager when that role exists,
@@ -212,13 +225,16 @@ module Maf
212
225
  # run the team. Dispatched workers cost no tokens while idle, so the rules
213
226
  # scale on backlog, not on cost.
214
227
  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.
228
+ - Only the user sets the team budget: `maf team set --max <n> --allow <harness[:model]> ...`.
229
+ If the user gives you a budget in words, give the user that command.
230
+ With a budget, manage the team yourself in dispatch mode. `maf prepare ... --dispatch` starts the worker in the background.
231
+ In the macOS sandbox, maf cannot start it. Give the user the two commands that it prints.
217
232
  - Check the team with `maf team`. It shows the budget, each worker, and the tasks by role.
218
233
  - Staff the architect first, with `--dispatch`. A goal needs the architect before any other worker.
219
234
  A dispatched architect starts on each message. An idle interactive architect reads nothing.
220
235
  - 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.
236
+ - If coord reports "No worker runs role <role>", add a worker for that role if the budget has a free slot.
237
+ Without a budget, tell the user.
222
238
  - If a role has more than three backlog tasks and the budget has a free slot, add a worker for that role.
223
239
  - If a role has no tasks and no open goal needs it, retire its extra workers.
224
240
  Keep one worker per role that an open goal needs.
@@ -242,7 +258,8 @@ module Maf
242
258
 
243
259
  Rules:
244
260
  - 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.
261
+ - Change the team only when the user asks, or within the team budget. Without a budget, `maf prepare` refuses.
262
+ Then give the user the command, or ask the user for a budget.
246
263
  Add a worker: `maf prepare <harness> <role>_<n>`.
247
264
  Replace a worker: `maf prepare <harness> <role>_<n> --replace <old-role>_<n>`.
248
265
  Remove a worker: `maf retire <role>_<n>`.
@@ -255,6 +272,7 @@ module Maf
255
272
  - Do not read the project docs in full. Each file that you read stays in your context for the whole session.
256
273
  Ask the architect, or start one subagent that returns a summary of at most 300 words.
257
274
  - If no report has arrived yet, tell the user and check again with `coord inbox project-manager`.
275
+ #{HOST_OPS_RULE}
258
276
  #{STE_RULE}
259
277
  #{SUBAGENT_RULE}
260
278
  LOOP