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.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # op_command.rb - list, approve, or deny the host operations that wait for the user.
4
+ #
5
+ # maf op list | approve ID | deny ID
6
+ require "rbconfig"
7
+ require_relative "team"
8
+
9
+ module Maf
10
+ # OpCommand passes the command to .maf/bin/steward. Only the user runs it:
11
+ # an approved operation runs on the host, outside the sandbox.
12
+ class OpCommand
13
+ USAGE = "usage: maf op list | approve ID | deny ID"
14
+ AGENT = "maf: only the user approves host operations. Ask the user to run this command in a terminal."
15
+
16
+ def initialize(argv, root)
17
+ @argv = argv
18
+ @root = root
19
+ end
20
+
21
+ def run
22
+ abort AGENT if Team.agent?
23
+ abort USAGE unless %w[list approve deny].include?(@argv.first)
24
+
25
+ exec(RbConfig.ruby, File.join(@root, ".maf", "bin", "steward"), *@argv)
26
+ end
27
+ end
28
+ end
@@ -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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # session_records.rb - the live sessions that maf start registered.
4
+ require "json"
5
+ require "time"
6
+ require_relative "shared/processes"
7
+
8
+ module Maf
9
+ # SessionRecords reads .maf/coordination/sessions/*.maf.json. The session
10
+ # guard writes one record for each maf start. A record is live while its pid
11
+ # runs and the process started before the record was written, so a reused
12
+ # pid does not count.
13
+ class SessionRecords
14
+ LSTART = "%a %b %d %H:%M:%S %Y %z"
15
+
16
+ def initialize(coord_dir)
17
+ @dir = File.join(coord_dir, "sessions")
18
+ end
19
+
20
+ def live = Dir.glob(File.join(@dir, "*.maf.json")).filter_map { |path| entry(path) }
21
+ def find(worker) = live.find { |record| record["worker"] == worker }
22
+
23
+ private
24
+
25
+ def entry(path)
26
+ record = JSON.parse(File.read(path))
27
+ started = Shared::Processes.started_at(record["pid"])
28
+ record.merge("started" => started) if live?(record["pid"], started, File.mtime(path))
29
+ rescue JSON::ParserError, SystemCallError
30
+ nil
31
+ end
32
+
33
+ def live?(pid, started, registered)
34
+ Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
35
+ rescue ArgumentError
36
+ false
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Maf
6
+ module SetupAgent
7
+ # Steward starts the steward daemon of the project. It runs before the
8
+ # agent variables are set, so steward does not run as an agent.
9
+ module Steward
10
+ def self.ensure(root)
11
+ script = File.join(root, ".maf", "bin", "steward")
12
+ return if ENV["STEWARD_SKIP"] || !File.exist?(script)
13
+
14
+ system(RbConfig.ruby, script, chdir: root, out: File::NULL, err: File::NULL)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -81,6 +81,7 @@ module Maf
81
81
  end
82
82
 
83
83
  def self.launch(args, _worktree)
84
+ Steward.ensure(Project.root)
84
85
  identify(args)
85
86
  return Dispatch.launch(args) if args.dispatch
86
87
 
@@ -106,4 +107,5 @@ require_relative "setup_agent/worktree"
106
107
  require_relative "setup_agent/launcher"
107
108
  require_relative "setup_agent/project"
108
109
  require_relative "setup_agent/runtime_hooks"
110
+ require_relative "setup_agent/steward"
109
111
  require_relative "../../assets/harness-hooks/session-guard"
@@ -0,0 +1,33 @@
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 "harness"
7
+
8
+ module Maf
9
+ module Shared
10
+ # FlowFiles finds the files of the flow in a worktree. A branch from before
11
+ # maf untrack still tracks them, and maf rewrites them in each worktree.
12
+ module FlowFiles
13
+ def self.path?(path) = path.start_with?(".maf/") || Harness.all.flat_map(&:runtime_files).include?(path)
14
+
15
+ # Only a change that a merge of TARGET deletes is discarded. Returns true when no change is left.
16
+ def self.discard(dir, target)
17
+ paths = changed(dir)
18
+ return true if paths.empty?
19
+ return false unless discardable?(dir, target, paths)
20
+
21
+ system("git", "-C", dir, "checkout", "-q", "HEAD", "--", *paths)
22
+ end
23
+
24
+ def self.discardable?(dir, target, paths)
25
+ paths.all? { |path| path?(path) } && git(dir, "ls-tree", "-r", "--name-only", target, "--", *paths).empty?
26
+ end
27
+
28
+ def self.tracked?(dir) = !git(dir, "ls-files", "--", ".maf").empty?
29
+ def self.changed(dir) = git(dir, "diff", "--name-only", "HEAD")
30
+ def self.git(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:readlines).map(&:chomp)
31
+ end
32
+ end
33
+ end
@@ -24,11 +24,12 @@ module Maf
24
24
 
25
25
  attr_reader :root
26
26
 
27
- def initialize(harnesses, root, coord_dir, extra)
27
+ def initialize(harnesses, root, coord_dir, policy)
28
28
  @harness = HarnessFiles.new(harnesses, ->(paths) { real(paths) })
29
29
  @root = root
30
30
  @coord_dir = coord_dir || File.join(root, ".maf", "coordination")
31
- @extra = extra.map { |path| File.expand_path(path, root) }
31
+ @extra = Array(policy["write"]).map { |path| File.expand_path(path, root) }
32
+ @app_paths = Array(policy["launch_services"]).map { |path| File.expand_path(path, root) }
32
33
  end
33
34
 
34
35
  def dirs = @dirs ||= real([@root, @coord_dir, *@extra, ENV.fetch("TMPDIR", nil), *COMMON])
@@ -45,6 +46,13 @@ module Maf
45
46
  end
46
47
 
47
48
  def existing = dirs.select { |dir| File.exist?(dir) }
49
+
50
+ def apps = @apps ||= real(@app_paths).select { |path| File.exist?(path) }.each { |path| fixed_app!(path) }
51
+
52
+ def fixed_app!(path)
53
+ writable?(path) && abort("maf: #{path} in launch_services is in a folder that the agent can change.")
54
+ end
55
+
48
56
  def sockets = real([@root, @coord_dir])
49
57
 
50
58
  def protected
@@ -11,18 +11,23 @@ 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")'
17
18
  TMP_PREFIX = "maf-sandbox-"
18
19
  SERVICES = "(deny mach-lookup (global-name-regex #\"^com\\.apple\\.(coreservices|lsd)\\.\"))\n" \
19
20
  "(deny appleevent-send)\n"
21
+ LAUNCH_SERVICES = "com.apple.coreservices.launchservicesd"
20
22
 
21
23
  def self.wrap(cmd, paths)
22
24
  tmp = private_tmp(paths.root)
23
- [COMMAND, "-p", profile(paths, tmp), "env", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache", *cmd]
25
+ [COMMAND, "-p", profile(paths, tmp), "env", "#{MARKER}=1", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache",
26
+ *cmd]
24
27
  end
25
28
 
29
+ def self.inside? = ENV[MARKER] == "1"
30
+
26
31
  def self.available? = File.executable?(COMMAND)
27
32
  def self.require! = available? || abort("maf: sandbox mode seatbelt needs #{COMMAND} (macOS only).")
28
33
 
@@ -48,7 +53,23 @@ module Maf
48
53
 
49
54
  def self.profile(paths, tmp)
50
55
  "(version 1)\n(allow default)\n(deny file-write*)\n#{writes(paths)}#{protects(paths)}" \
51
- "#{sockets([*paths.sockets, tmp])}#{SERVICES}"
56
+ "#{sockets([*paths.sockets, tmp])}#{SERVICES}#{launch_services(paths.apps)}"
57
+ end
58
+
59
+ # A later rule wins, so this exception comes after the denial.
60
+ def self.launch_services(apps)
61
+ programs = apps.flat_map { |app| programs(app) }.uniq
62
+ return "" if programs.empty?
63
+
64
+ filters = programs.map { |path| "(process-path #{quote(path)})" }
65
+ "(allow mach-lookup (require-all (global-name \"#{LAUNCH_SERVICES}\")\n " \
66
+ "(require-any #{filters.join("\n ")})))\n"
67
+ end
68
+
69
+ def self.programs(app)
70
+ return [app] unless File.directory?(app)
71
+
72
+ Dir.glob(File.join(app, "**", "Contents", "MacOS", "*")).select { |path| File.file?(path) }
52
73
  end
53
74
 
54
75
  def self.writes(paths)
@@ -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
@@ -68,7 +74,7 @@ module Maf
68
74
 
69
75
  def self.paths(harness, policy, coord_dir)
70
76
  protect_policy
71
- Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, Array(policy["write"]))
77
+ Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, policy)
72
78
  end
73
79
 
74
80
  def self.project_harnesses = Project.manifest.fetch("agents", []).filter_map { |a| Harness[a["harness"]] }
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
 
@@ -9,6 +9,7 @@ module Maf
9
9
  ".maf/bin/dashboard.html" => Bootstrap::DASHBOARD_PAGE_SIGNATURE,
10
10
  ".maf/bin/analyst" => Bootstrap::ANALYST_SIGNATURE,
11
11
  ".maf/bin/vault" => Bootstrap::VAULT_SIGNATURE,
12
+ ".maf/bin/steward" => Bootstrap::STEWARD_SIGNATURE,
12
13
  ".maf/bin/doc-graph-refresh" => Bootstrap::DOC_GRAPH_SIGNATURE,
13
14
  ".maf/env.sh" => Bootstrap::ENV_SIGNATURE,
14
15
  Bootstrap::OPENCODE_PLUGIN => Bootstrap::OPENCODE_BOARD_WATCH_SIGNATURE }.freeze
data/lib/maf/untrack.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  # untrack.rb - move an install that git tracks to the local layout.
4
4
  require "json"
5
+ require "rbconfig"
5
6
  require_relative "uninstall"
6
7
 
7
8
  module Maf
@@ -23,6 +24,21 @@ module Maf
23
24
 
24
25
  def self.warn_tracked(root) = (warn TRACKED unless new(root, []).tracked.empty?)
25
26
 
27
+ def self.after_update(root)
28
+ warn_tracked(root)
29
+ migrate_goals(root)
30
+ end
31
+
32
+ def self.migrate_goals(root)
33
+ coord = File.join(root, ".maf", "bin", "coord")
34
+ system(RbConfig.ruby, coord, "goal", "migrate", chdir: root) if File.exist?(coord)
35
+ end
36
+
37
+ DONE = ["", "The flow left git. Review the change with `git status`, then commit it.",
38
+ "A branch that still tracks the flow files can delete them in a checkout or a merge.",
39
+ "After you push the commit, maf start fast-forwards each worker, and maf update syncs each goal.",
40
+ "maf keeps a backup of the flow config in .git/maf/backup. Run `maf restore` to put it back."].freeze
41
+
26
42
  def run
27
43
  steps = plan
28
44
  return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
@@ -55,7 +71,7 @@ module Maf
55
71
  abort "maf: cancelled" unless @yes || confirmed?
56
72
  steps.each(&:run)
57
73
  Dir.chdir(@root) { Maf.flow }
58
- puts "", "The flow left git. Review the change with `git status`, then commit it."
74
+ puts DONE
59
75
  end
60
76
 
61
77
  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.5.0"
5
5
  end
@@ -8,6 +8,7 @@ require_relative "workers"
8
8
  require_relative "retire"
9
9
  require_relative "role_limits"
10
10
  require_relative "shared/processes"
11
+ require_relative "session_records"
11
12
 
12
13
  module Maf
13
14
  # WorkerControl stops, starts, or restarts one worker. Each action is
@@ -17,8 +18,12 @@ module Maf
17
18
  # only when the session is idle. maf then prints the start command.
18
19
  class WorkerControl
19
20
  ACTIONS = %w[status stop start restart].freeze
21
+ USAGE = "usage: maf worker list | #{ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
22
+ "[--max-context N] [--max-session-runs N] [--cache-window S]".freeze
20
23
  MAF = File.expand_path("../../exe/maf", __dir__)
21
24
  IDLE = 60
25
+ STUCK = "maf: %<worker>s did not stop after TERM. Its terminal may be closed. " \
26
+ "Stop it with: pkill -9 -P %<pid>s; kill -9 %<pid>s"
22
27
 
23
28
  # force stops an interactive session also when maf cannot tell if it is idle.
24
29
  # limits are session limits of the role. start and restart save them first.
@@ -83,8 +88,12 @@ module Maf
83
88
  abort "maf: #{@worker} is in a turn. Try again when the session is idle." if busy?
84
89
  abort "maf: maf cannot tell if #{@worker} is idle. Stop it in its terminal, or add --force." if unknown?
85
90
 
91
+ terminate_session
92
+ end
93
+
94
+ def terminate_session
86
95
  RunningProcesses.terminate(pid)
87
- RunningProcesses.wait_for_exit(pid, timeout: 10)
96
+ abort format(STUCK, worker: @worker, pid: pid) unless RunningProcesses.wait_for_exit(pid, timeout: 10)
88
97
  puts "Stopped #{@worker}. To start it again: #{start_hint}"
89
98
  end
90
99
 
@@ -95,7 +104,8 @@ module Maf
95
104
 
96
105
  def start_hint = "cd #{@entry["dir"]} && maf start"
97
106
  def coord_dir = File.join(@root, ".maf", "coordination")
98
- def presence = read(File.join(coord_dir, "presence", "#{@worker}.json"))
107
+ def presence = @presence ||= SessionRecords.new(coord_dir).find(@worker) || presence_file
108
+ def presence_file = read(File.join(coord_dir, "presence", "#{@worker}.json"))
99
109
  def pid = presence["pid"].to_i
100
110
  def live? = Shared::Processes.live?(presence)
101
111
  def ours? = Shared::Processes.same?(pid, presence["started"])
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require_relative "workers"
7
7
  require_relative "shared/processes"
8
+ require_relative "session_records"
8
9
 
9
10
  module Maf
10
11
  # WorkerList shows each running worker process of the project. A worker
@@ -12,7 +13,6 @@ module Maf
12
13
  class WorkerList
13
14
  Row = Struct.new(:worker, :role, :harness, :mode, :pid, :started)
14
15
  HEADER = %w[WORKER ROLE HARNESS MODE PID STARTED].freeze
15
- LSTART = "%a %b %d %H:%M:%S %Y %z"
16
16
 
17
17
  def initialize(root)
18
18
  @root = root
@@ -31,18 +31,7 @@ module Maf
31
31
  def coord_dir = File.join(@root, ".maf", "coordination")
32
32
  def records(dir, pattern) = Dir.glob(File.join(coord_dir, dir, pattern)).map { |path| [path, read(path)] }
33
33
 
34
- def sessions
35
- records("sessions", "*.maf.json").filter_map do |path, record|
36
- row(record, "session") if session_live?(record["pid"], File.mtime(path))
37
- end
38
- end
39
-
40
- def session_live?(pid, registered)
41
- started = Shared::Processes.started_at(pid)
42
- Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
43
- rescue ArgumentError
44
- false
45
- end
34
+ def sessions = SessionRecords.new(coord_dir).live.map { |record| row(record, "session") }
46
35
 
47
36
  def dispatchers
48
37
  records("presence", "*.json").filter_map do |_, entry|
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dominik Alberski
@@ -53,12 +53,14 @@ files:
53
53
  - assets/harness-hooks/next-task.rb
54
54
  - assets/harness-hooks/session-guard.rb
55
55
  - assets/lima/maf.yaml.erb
56
+ - assets/steward
56
57
  - assets/taskrc.append
57
58
  - assets/vault
58
59
  - assets/worktree-env.example.rb
59
60
  - exe/maf
60
61
  - install.md
61
62
  - lib/maf.rb
63
+ - lib/maf/backup.rb
62
64
  - lib/maf/bootstrap.rb
63
65
  - lib/maf/bootstrap/claude_settings.rb
64
66
  - lib/maf/bootstrap/dependencies.rb
@@ -114,9 +116,12 @@ files:
114
116
  - lib/maf/migrate/role_files.rb
115
117
  - lib/maf/migrate/runner.rb
116
118
  - lib/maf/migrate/worktrees.rb
119
+ - lib/maf/op_command.rb
117
120
  - lib/maf/prompt.rb
121
+ - lib/maf/restore.rb
118
122
  - lib/maf/retire.rb
119
123
  - lib/maf/role_limits.rb
124
+ - lib/maf/session_records.rb
120
125
  - lib/maf/setup_agent.rb
121
126
  - lib/maf/setup_agent/args.rb
122
127
  - lib/maf/setup_agent/dispatch.rb
@@ -126,7 +131,9 @@ files:
126
131
  - lib/maf/setup_agent/pty_relay.rb
127
132
  - lib/maf/setup_agent/role_file.rb
128
133
  - lib/maf/setup_agent/runtime_hooks.rb
134
+ - lib/maf/setup_agent/steward.rb
129
135
  - lib/maf/setup_agent/worktree.rb
136
+ - lib/maf/shared/flow_files.rb
130
137
  - lib/maf/shared/git_exclude.rb
131
138
  - lib/maf/shared/git_identity.rb
132
139
  - lib/maf/shared/harness.rb