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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d92abf8bbcca7ba5044ac6185534e883a55a1387e05460ac4142e1d89f3d13e
4
- data.tar.gz: 25eaa46e4ed438fef70a08841137fc87c7ede35dfe9c09ff4d053a351e11e9ef
3
+ metadata.gz: 9d59c62e5b37a338b1da8b57db74bfb670bb735ca4fa90059d6b874a18ffd25e
4
+ data.tar.gz: 2c33e35cec6ad395fc539a4bc5ef3813a2e73fdcdb17464a247dbfdf1111ec0d
5
5
  SHA512:
6
- metadata.gz: 96324cff6b379cb0ec71cbe0d8a15d127a950ddfb7e3997012f4f9b6c4759508a34668fc06a7702d04030ebf65875335c05a6a5836f7959422cbaf107d6abe92
7
- data.tar.gz: 9faa6836a004d143d9bcdfc7852d4479b2f9b43f40d09efcab44c32343607b9bcf73949c7e62235b772f817d37845e0e2a5955deefab1c576477a962798e8aa2
6
+ metadata.gz: 1fe86c50be42049d5dedef7e561c6e042469d7686828bc7932931d63cece3f6fcb54e516e08c2fef48792d5fd86aa4e16932d7e5d3e7e7ae72dd1bc9f6a0f72b
7
+ data.tar.gz: 19f1244d7243c253c6f1852fd1376028e25c9e67d0bd249cb0e3fc44f25dd88b7b293ab466eb670d5040faec8e48c87fe00a8cb1a1cc02a81677a0eff37f9742
data/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.0] - 2026-10-22
6
+
7
+ - Fix: in the macOS sandbox, headless Chrome aborted at start, and macOS showed a crash dialog
8
+ for each try. New `.maf/sandbox.json` key `launch_services` lists the apps that can use
9
+ LaunchServices. After each run, the dispatcher reports apps that the run started and that
10
+ crashed, and LaunchServices denials, once for each app to the project manager.
11
+ - Fix: after Ctrl+C or SIGHUP, the dispatcher stopped, but the agent run continued in its own
12
+ session. The dispatcher now stops the process group of the agent first.
13
+ - New: `steward`, a daemon outside the sandbox that runs a fixed list of host operations for the
14
+ leads: `worker-restart`, `worker-retire`, `worktree-sync`, `goal-sync`, `runtime-restore`. An
15
+ agent asks with `coord op request OPERATION [ARGUMENT]`. steward checks each argument, runs no
16
+ shell, allows 3 runs per hour for one argument, and sends the result to the inbox of the role
17
+ that asked. `maf start` starts steward. The project manager and the architect prompts list the
18
+ operations. `maf update` installs `.maf/bin/steward`.
19
+ - New: operations that wait for the user: `worker-start`, `worker-model WORKER MODEL`, and
20
+ `bundle-install` (frozen to `Gemfile.lock`). `maf op list|approve ID|deny ID` handles them.
21
+ `.maf/sandbox.json` key `ops` sets the level of each operation: `auto`, `approve`, or `off`.
22
+ - After `maf untrack`, maf moves the old branches. `maf start` fast-forwards a worker branch
23
+ also when the only changes are in flow files that the base branch no longer tracks: maf
24
+ discards them first. `maf update` runs the new `coord goal migrate`, which merges the base
25
+ branch into each goal branch that still tracks the flow files. Before, the user did these
26
+ steps by hand, because the sandbox blocks an agent from deleting the flow files.
27
+ - Fix: `maf worker stop` did nothing for a session that `maf worker list` showed as running.
28
+ `stop` read only the presence file, and that file can name an older session. `stop`, `start`,
29
+ and `status` now find the session from the records of `maf start`, as `list` does.
30
+ - `maf worker stop` reports a session that does not stop after TERM, and prints the `kill -9`
31
+ command. A session in a closed terminal can ignore TERM.
32
+
33
+ ## [0.4.0] - 2026-10-21
34
+
35
+ - Fix: in the macOS sandbox, a worker that the project manager started failed on each run with
36
+ `sandbox_apply: Operation not permitted`. macOS cannot start a sandbox inside a sandbox. The
37
+ sandbox now sets `MAF_SEATBELT=1`. In the sandbox, `maf prepare --dispatch` prepares the worker
38
+ and prints the start commands, and a dispatcher stops at once with the reason in its log.
39
+ - The project manager no longer adds workers on its own. Without a team budget, `maf prepare`
40
+ refuses when an agent runs it. Only the user sets the budget: `maf team set` refuses when an
41
+ agent runs it.
42
+ - Backup of the flow config. Each maf command copies `.maf/config.json`, `roles.yml`,
43
+ `workflow.md`, and `sandbox.json` to `.git/maf/backup/` when they changed, and keeps 10
44
+ copies. New command `maf restore [--list] [BACKUP] [--force]` puts them back and runs
45
+ `maf update`. When `.maf/` exists without `config.json`, maf names the backup. Before, a
46
+ merge of the `maf untrack` commit into a branch that still tracked the flow deleted the
47
+ config, and the agents, `base_branch`, and `github` settings were lost.
48
+
5
49
  ## [0.3.0] - 2026-10-21
6
50
 
7
51
  - 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/coord CHANGED
@@ -25,6 +25,7 @@ begin
25
25
  require_relative "../lib/maf/shared/git_exclude"
26
26
  require_relative "../lib/maf/shared/git_identity"
27
27
  require_relative "../lib/maf/shared/harness"
28
+ require_relative "../lib/maf/shared/flow_files"
28
29
  rescue LoadError
29
30
  abort "coord: the maf shared library is missing. Run maf update."
30
31
  end
@@ -72,6 +73,7 @@ module Coord
72
73
  coord goal list list open goals
73
74
  coord goal show ID show a goal and its tasks
74
75
  coord goal sync ID merge the base branch into the goal branch
76
+ coord goal migrate sync each goal branch that still tracks the flow files (after maf untrack)
75
77
  coord goal done ID close a goal (refused while a task is open or the goal lacks the base head)
76
78
  coord goal pr ID push the goal branch as the GitHub bot and open or update its pull request
77
79
  coord review-watch [--once] [--interval S]
@@ -97,6 +99,10 @@ module Coord
97
99
  coord lesson ID dead_end|corrected TEXT...
98
100
  save a lesson of a task to the graph memory: an approach that
99
101
  failed (dead_end), or the right way (corrected)
102
+ coord op request OPERATION [ARGUMENT...] ask steward for a host operation; the result comes to your inbox.
103
+ auto: worker-restart W, worker-retire W, worktree-sync W, goal-sync ID,
104
+ runtime-restore. The user approves: worker-start W, worker-model W MODEL,
105
+ bundle-install
100
106
  coord msg [--from A] [--task ID] [--fyi] TO TEXT...
101
107
  TO is a role or a worker; a worker's mail goes to its role inbox.
102
108
  --task also saves the text as a note on the task.
@@ -134,7 +140,7 @@ module Coord
134
140
  "land" => :cmd_land, "gc" => :cmd_gc, "reap" => :cmd_reap, "review-watch" => :cmd_review_watch,
135
141
  "show" => :cmd_show, "escalate" => :cmd_escalate,
136
142
  "status" => :cmd_status, "board" => :cmd_board, "export" => :cmd_export,
137
- "who" => :cmd_who, "help" => :cmd_help
143
+ "who" => :cmd_who, "op" => :cmd_op, "help" => :cmd_help
138
144
  }.freeze
139
145
 
140
146
  ADD_OPTS = { "--role" => :role, "--scope" => :scope, "--ref" => :ref,
@@ -1260,8 +1266,10 @@ end
1260
1266
  goal
1261
1267
  end
1262
1268
 
1269
+ def open = @tasks.pending.select { |task| task["role"] == ROLE }
1270
+
1263
1271
  def list
1264
- goals = @tasks.pending.select { |task| task["role"] == ROLE }
1272
+ goals = open
1265
1273
  return puts("no open goals") if goals.empty?
1266
1274
 
1267
1275
  goals.each { |goal| puts goal_line(goal) }
@@ -1332,7 +1340,7 @@ end
1332
1340
  paths = Git.changed(@dir)
1333
1341
  return if paths.empty?
1334
1342
 
1335
- flow = paths.all? { |path| path.start_with?(".maf/") || Worktree::RUNTIME_FILES.include?(path) }
1343
+ flow = paths.all? { |path| Maf::Shared::FlowFiles.path?(path) || Worktree::RUNTIME_FILES.include?(path) }
1336
1344
  raise Error, ["uncommitted changes in #{@dir}: #{paths.first(5).join(", ")}#{" ..." if paths.size > 5}.",
1337
1345
  flow ? FLOW_FILES : "Commit or stash them first."].join(" ")
1338
1346
  end
@@ -1496,11 +1504,13 @@ end
1496
1504
 
1497
1505
  # A missing base ref has no head to lack.
1498
1506
  def behind? = Git.run(@root, "rev-parse", "-q", "--verify", ref) && !Git.ancestor?(@root, ref, @branch)
1507
+ def untracked_upstream? = Dir.exist?(@dir) && Maf::Shared::FlowFiles.tracked?(@dir) && !flow_in_ref?
1508
+ def flow_in_ref? = !Git.out(@root, "ls-tree", "--name-only", ref, "--", ".maf").empty?
1499
1509
  def ref = @ref ||= Git.base_ref(@root, @base)
1500
1510
 
1501
1511
  def run
1502
1512
  return puts("#{@branch} already contains #{ref}") unless behind?
1503
- raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
1513
+ raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless clean?
1504
1514
 
1505
1515
  merge
1506
1516
  puts "merged #{ref} into #{@branch}"
@@ -1508,6 +1518,8 @@ end
1508
1518
 
1509
1519
  private
1510
1520
 
1521
+ def clean? = Maf::Shared::FlowFiles.discard(@dir, ref) && Git.clean?(@dir)
1522
+
1511
1523
  def merge
1512
1524
  return if Git.run(@dir, "merge", "-q", "--no-edit", ref)
1513
1525
 
@@ -1945,14 +1957,18 @@ end
1945
1957
 
1946
1958
  private
1947
1959
 
1948
- def checks
1960
+ # The flow files are discarded last, only when the fast-forward can run.
1961
+ def checks = ready_checks + [[-> { clean? }, "the worktree has uncommitted changes"]]
1962
+
1963
+ def ready_checks
1949
1964
  [[-> { head == @branch }, "#{@branch} is not checked out"],
1950
- [-> { Git.clean?(@dir) }, "the worktree has uncommitted changes"],
1951
1965
  [-> { Git.run(@dir, "fetch", "-q", "origin", @base) }, "git fetch origin #{@base} failed"],
1952
1966
  [-> { Git.run(@dir, "merge-base", "--is-ancestor", "HEAD", target) },
1953
1967
  "#{@branch} has commits that #{target} lacks"]]
1954
1968
  end
1955
1969
 
1970
+ def clean? = Maf::Shared::FlowFiles.discard(@dir, target) && Git.clean?(@dir)
1971
+
1956
1972
  def merge
1957
1973
  return if rev("HEAD") == rev(target)
1958
1974
 
@@ -2183,6 +2199,7 @@ end
2183
2199
  def shift = @argv.shift
2184
2200
  def first = @argv.first
2185
2201
  def empty? = @argv.empty?
2202
+ def rest = @argv.shift(@argv.size)
2186
2203
  def join = @argv.join(" ")
2187
2204
 
2188
2205
  # Splits argv at the first literal "--" and returns the words after it,
@@ -2669,11 +2686,11 @@ end
2669
2686
  # Goal commands create, sync, land, and close goals.
2670
2687
  module GoalCommands
2671
2688
  GOAL_COMMANDS = { "add" => :goal_add, "list" => :goal_list, "show" => :goal_show, "done" => :goal_done,
2672
- "sync" => :goal_sync, "pr" => :goal_pr }.freeze
2689
+ "sync" => :goal_sync, "migrate" => :goal_migrate, "pr" => :goal_pr }.freeze
2673
2690
 
2674
2691
  def cmd_goal
2675
2692
  sub = @args.shift
2676
- abort "usage: coord goal add|list|show|sync|pr|done" unless GOAL_COMMANDS.key?(sub)
2693
+ abort "usage: coord goal add|list|show|sync|migrate|pr|done" unless GOAL_COMMANDS.key?(sub)
2677
2694
 
2678
2695
  send(GOAL_COMMANDS[sub])
2679
2696
  end
@@ -2713,6 +2730,20 @@ end
2713
2730
  event_log.append("goal-sync", @worker, goal["description"])
2714
2731
  end
2715
2732
 
2733
+ def goal_migrate = goals.open.each { |goal| migrate_goal(goal) }
2734
+
2735
+ def migrate_goal(goal)
2736
+ sync = goal_sync_for(goal)
2737
+ sync.untracked_upstream? && run_migration(sync, goal)
2738
+ end
2739
+
2740
+ def run_migration(sync, goal)
2741
+ sync.run
2742
+ event_log.append("goal-sync", @worker, goal["description"])
2743
+ rescue Error => e
2744
+ warn "coord: #{e.message}"
2745
+ end
2746
+
2716
2747
  def goal_sync_for(goal) = GoalSync.new(main_root, goal["uuid"], Git.base_branch(main_root, manifest))
2717
2748
 
2718
2749
  def goal_done
@@ -2890,6 +2921,26 @@ end
2890
2921
  end
2891
2922
  end
2892
2923
 
2924
+ # OpCommands writes a request for steward. steward validates and runs it on the host.
2925
+ module OpCommands
2926
+ OP_USAGE = "usage: coord op request OPERATION [ARGUMENT...]"
2927
+
2928
+ def cmd_op
2929
+ abort OP_USAGE unless @args.shift == "request" && (op = @args.shift)
2930
+
2931
+ id = "#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{format("%06x", rand(0xffffff))}"
2932
+ write_op(id, "id" => id, "op" => op, "args" => @args.rest, "role" => @role, "worker" => @worker)
2933
+ puts "op #{id} requested. steward sends the result to the #{@role} inbox."
2934
+ end
2935
+
2936
+ def write_op(id, request)
2937
+ dir = File.join(paths.coord_dir, "ops", "requests")
2938
+ FileUtils.mkdir_p(dir)
2939
+ File.write(File.join(dir, "#{id}.json"), JSON.generate(request.merge("at" => Time.now.utc.iso8601)))
2940
+ event_log.append("op-request", @worker, [request["op"], *request["args"]].join(" "))
2941
+ end
2942
+ end
2943
+
2893
2944
  # Upkeep commands handle locks, the event log, worktrees, and cleanup.
2894
2945
  module UpkeepCommands
2895
2946
  def cmd_lock
@@ -3029,6 +3080,7 @@ end
3029
3080
  include GoalCommands
3030
3081
  include ReviewCommands
3031
3082
  include UpkeepCommands
3083
+ include OpCommands
3032
3084
  include CommandHelpers
3033
3085
  include Wiring
3034
3086
 
data/assets/dispatcher CHANGED
@@ -137,6 +137,7 @@ require "fileutils"
137
137
  require "optparse"
138
138
  require "rbconfig"
139
139
  require "shellwords"
140
+ require "time"
140
141
  begin
141
142
  require_relative "../lib/maf/shared/processes"
142
143
  require_relative "../lib/maf/shared/project"
@@ -175,6 +176,13 @@ module Dispatcher
175
176
  end
176
177
  end
177
178
 
179
+ def self.refuse_nested
180
+ return unless Maf::Shared::Sandbox.nested?
181
+
182
+ Log.line(Maf::Shared::Sandbox::NESTED.delete_prefix("maf: "))
183
+ exit 1
184
+ end
185
+
178
186
  # Project finds the main checkout from any worktree. Lead roles own no
179
187
  # tasks: they get an inbox prompt and no task polling.
180
188
  Project = Maf::Shared::Project
@@ -683,8 +691,10 @@ module Dispatcher
683
691
 
684
692
  # Spawn runs a command in its own process group. Any end reason except a
685
693
  # normal exit kills the whole group (TERM, then KILL), which also stops the
686
- # children of an `sh -c` template. A completion signal is success even when
687
- # the process must be killed. An abort signal is failure.
694
+ # children of an `sh -c` template. An exception in the dispatcher (Ctrl+C,
695
+ # SIGHUP) also kills the group, so no agent run outlives the dispatcher.
696
+ # A completion signal is success even when the process must be killed.
697
+ # An abort signal is failure.
688
698
  module Spawn
689
699
  GRACE = 5
690
700
  TIMEOUTS = %i[timeout idle].freeze
@@ -717,6 +727,8 @@ module Dispatcher
717
727
  reason = watch.wait(waiter, stream)
718
728
  stop(pid, waiter) unless reason == :exit
719
729
  result(stream.close(watch.grace), waiter, watch, reason)
730
+ ensure
731
+ stop(pid, waiter) if waiter&.alive?
720
732
  end
721
733
 
722
734
  def self.result(output, waiter, watch, reason)
@@ -734,6 +746,71 @@ module Dispatcher
734
746
  end
735
747
  end
736
748
 
749
+ # SandboxCrashes reports, once per app, a program that a run started in the
750
+ # macOS sandbox and that crashed, or that the sandbox refused LaunchServices.
751
+ # macOS shows a crash dialog for each crash, so the user must learn the cause.
752
+ module SandboxCrashes
753
+ REPORTS = File.expand_path("~/Library/Logs/DiagnosticReports")
754
+ DENIAL = /Sandbox: (.+?)\(\d+\) deny\(\d+\) mach-lookup com\.apple\.coreservices\.launchservicesd/
755
+ PREDICATE = 'sender == "Sandbox" AND eventMessage CONTAINS "com.apple.coreservices.launchservicesd"'
756
+ NOTICE = "Dispatcher of %<worker>s: in the macOS sandbox, %<found>s. If the project needs the app, " \
757
+ "the user adds it to \"launch_services\" in .maf/sandbox.json. Then the app can start other " \
758
+ "apps outside the sandbox. An app with its own sandbox (Chrome) also needs it off (--no-sandbox), " \
759
+ "because macOS cannot start a sandbox in a sandbox. Tell the user."
760
+
761
+ @seen = []
762
+
763
+ def self.guard(harness, config, env, &block)
764
+ Maf::Shared::Sandbox.keeping_links(harness, coord_dir: config.coord_dir) { watch(config, env, &block) }
765
+ end
766
+
767
+ def self.watch(config, env, reports: REPORTS)
768
+ return yield unless Maf::Shared::Sandbox.mode == "seatbelt"
769
+
770
+ start = Time.now
771
+ yield.tap { report(config, env, crashes(start, reports), denials(start)) }
772
+ end
773
+
774
+ def self.report(config, env, crashes, denied)
775
+ crashes = crashes.except(*@seen)
776
+ denied -= @seen
777
+ return if crashes.empty? && denied.empty?
778
+
779
+ @seen.concat(crashes.keys, denied)
780
+ text = format(NOTICE, worker: config.worker, found: found(crashes, denied))
781
+ Log.line(text)
782
+ CoordCall.run(env, "escalate", text)
783
+ end
784
+
785
+ def self.found(crashes, denied)
786
+ parts = []
787
+ parts << "these apps crashed: #{crashes.map { |app, count| "#{app} (#{count}x)" }.join(", ")}" if crashes.any?
788
+ parts << "the sandbox refused LaunchServices to #{denied.join(", ")}" if denied.any?
789
+ parts.join("; ")
790
+ end
791
+
792
+ def self.crashes(start, reports)
793
+ files = Dir.glob(File.join(reports, "*.ips")).select { |path| File.mtime(path) >= start - 1 }
794
+ files.filter_map { |path| crashed_app(path, start) }.tally
795
+ end
796
+
797
+ def self.crashed_app(path, start)
798
+ body = JSON.parse(File.read(path).split("\n", 2).last.to_s)
799
+ return unless body["procPath"] && Time.parse(body["procLaunch"].to_s) >= start - 1
800
+
801
+ body["procPath"][%r{\A.*?\.app(?=/)}] || body["procPath"]
802
+ rescue SystemCallError, JSON::ParserError, ArgumentError
803
+ nil
804
+ end
805
+
806
+ def self.denials(start)
807
+ args = ["show", "--start", start.strftime("%Y-%m-%d %H:%M:%S"), "--style", "compact", "--predicate", PREDICATE]
808
+ IO.popen(["/usr/bin/log", *args], err: File::NULL, &:read).scan(DENIAL).flatten.uniq
809
+ rescue SystemCallError
810
+ []
811
+ end
812
+ end
813
+
737
814
  Events = Maf::Shared::Harness::Events
738
815
 
739
816
  # TokenUsage adds the token usage of one run to the totals of the worker in
@@ -886,9 +963,8 @@ module Dispatcher
886
963
  command = Project.manifest["verify"].to_s
887
964
  return true if command.empty? || LEADS.include?(config.role)
888
965
 
889
- sandbox = Maf::Shared::Sandbox
890
966
  run = -> { Spawn.run(sandboxed(command, config), env: env, timeout: config.timeout) }
891
- check(sandbox.keeping_links(Harness::Generic, coord_dir: config.coord_dir, &run), command)
967
+ check(SandboxCrashes.guard(Harness::Generic, config, env, &run), command)
892
968
  end
893
969
 
894
970
  def self.sandboxed(command, config)
@@ -1089,7 +1165,7 @@ module Dispatcher
1089
1165
  end
1090
1166
 
1091
1167
  def sandboxed(cmd) = Maf::Shared::Sandbox.wrap(cmd, @harness, coord_dir: @config.coord_dir)
1092
- def keeping_links(&block) = Maf::Shared::Sandbox.keeping_links(@harness, coord_dir: @config.coord_dir, &block)
1168
+ def keeping_links(&block) = SandboxCrashes.guard(@harness, @config, @env, &block)
1093
1169
 
1094
1170
  def session_label(id) = id ? "#{id}, idle #{(@session.idle_seconds / 60).round}m" : "new"
1095
1171
 
@@ -1512,5 +1588,6 @@ if __FILE__ == $PROGRAM_NAME
1512
1588
  ENV["PATH"] = [File.dirname(File.expand_path(__FILE__)), ENV["PATH"]].compact.join(File::PATH_SEPARATOR)
1513
1589
  # Each run inherits the git persona of the agents.
1514
1590
  Maf::Shared::GitIdentity.apply(Dispatcher::Project.manifest)
1591
+ Dispatcher.refuse_nested
1515
1592
  Dispatcher::Main.new(Dispatcher::Options.parse(ARGV)).run
1516
1593
  end