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
data/lib/maf/untrack.rb CHANGED
@@ -11,7 +11,6 @@ module Maf
11
11
  # locally, and runs maf update. The user reviews the change and commits it.
12
12
  class Untrack
13
13
  USAGE = "Usage: maf untrack [--check] [--yes]"
14
- PATHS = [".maf", ".opencode/agents", ".codex/prompts", Bootstrap::OPENCODE_PLUGIN].freeze
15
14
 
16
15
  def initialize(root, argv)
17
16
  @root = root
@@ -19,6 +18,11 @@ module Maf
19
18
  @yes = argv.include?("--yes")
20
19
  end
21
20
 
21
+ TRACKED = "maf: git tracks files of the flow. Each maf update changes them in every worktree, " \
22
+ "and coord start-task refuses a dirty worktree. Run maf untrack --check, then maf untrack, then commit."
23
+
24
+ def self.warn_tracked(root) = (warn TRACKED unless new(root, []).tracked.empty?)
25
+
22
26
  def run
23
27
  steps = plan
24
28
  return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
@@ -30,8 +34,8 @@ module Maf
30
34
  private
31
35
 
32
36
  def plan
33
- cleaners = [Uninstall::MarkedFiles.new(@root, keep_rules: false), Uninstall::ClaudeSettings.new(@root),
34
- Uninstall::McpEntries.new(@root)]
37
+ cleaners = [Uninstall::MarkedFiles.new(@root, keep_rules: false),
38
+ *Uninstall.harness_cleaners(@root, legacy_only: true), Uninstall::McpEntries.new(@root)]
35
39
  [untrack_step, *cleaners.flat_map(&:steps)].compact
36
40
  end
37
41
 
@@ -59,29 +63,12 @@ module Maf
59
63
  $stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
60
64
  end
61
65
 
62
- # The tracked paths of the flow, and each tracked link into .maf/agents/.
63
- def tracked = (git_files(*PATHS).map { |path| top(path) } + agent_links + codex_hooks).uniq
64
-
65
- def top(path) = path.start_with?(".maf/") ? ".maf" : path
66
-
67
- def agent_links
68
- git_files(".claude/agents").select { |path| File.symlink?(File.join(@root, path)) && link_to_flow?(path) }
69
- end
70
-
71
- def link_to_flow?(path) = File.readlink(File.join(@root, path)).include?("#{Bootstrap::MAF_DIR}/agents")
72
-
73
- # A .codex/hooks.json with only the flow hooks belongs to the flow.
74
- def codex_hooks
75
- path = Flow::CodexHooks::FILE
76
- git_files(path).select { |file| (hook_commands(file) - Flow::CodexHooks::COMMANDS).empty? }
77
- end
66
+ public
78
67
 
79
- def hook_commands(file)
80
- data = JSON.parse(File.read(File.join(@root, file)))
81
- entries = data.fetch("hooks", {}).values.flatten
82
- entries.flat_map { |entry| entry.fetch("hooks", []) }.map { |hook| hook["command"] }
83
- rescue JSON::ParserError
84
- [nil]
68
+ # The tracked files of .maf/, and the tracked paths of each harness (Harness#untrack_paths).
69
+ def tracked
70
+ harness_paths = Maf::Shared::Harness.all.flat_map { |harness| harness.untrack_paths(@root, method(:git_files)) }
71
+ ((git_files(".maf").empty? ? [] : [".maf"]) + harness_paths).uniq
85
72
  end
86
73
 
87
74
  def git_files(*paths) = IO.popen(["git", "-C", @root, "ls-files", "--", *paths], err: File::NULL, &:readlines)
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.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../prompt"
5
+
6
+ module Maf
7
+ class Vm
8
+ # Login copies the login of each harness from the host into the VM.
9
+ class Login
10
+ # The command runs in the VM. It writes stdin to a new file with mode 600,
11
+ # then moves the file to ~/DEST. An old file at DEST does not keep its mode.
12
+ WRITE = 'cd && umask 077 && mkdir -p "$(dirname "$1")" && tmp=$(mktemp "$1.XXXXXX") && ' \
13
+ 'cat > "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$1"'
14
+
15
+ def initialize(name, harnesses, mode, prompt = Prompt.new)
16
+ @name = name
17
+ @harnesses = harnesses
18
+ @mode = mode
19
+ @prompt = prompt
20
+ end
21
+
22
+ def run = @harnesses.each { |harness| copy?(harness) ? copy(harness) : hint(harness) }
23
+
24
+ private
25
+
26
+ def copy?(harness)
27
+ return false if harness.vm_credentials.empty? || @mode == :none
28
+
29
+ @mode == :all || @prompt.confirm?("Copy your #{harness.name} login from this computer into the VM?")
30
+ end
31
+
32
+ def copy(harness)
33
+ logins = harness.vm_credentials.filter_map { |credential| (text = credential.read) && [credential.dest, text] }
34
+ return hint(harness, "maf found no login on this computer. ") if logins.empty?
35
+
36
+ logins.each { |dest, text| written?(dest, text) || abort(not_running) }
37
+ @prompt.say("copied #{harness.name} login")
38
+ end
39
+
40
+ def not_running = "maf: cannot write to the VM #{@name}. Run: maf vm start"
41
+
42
+ def written?(dest, text)
43
+ command = [LIMACTL, "shell", "--workdir", "/", @name, "sh", "-c", WRITE, "sh", dest]
44
+ Open3.capture2e(*command, stdin_data: text).last.success?
45
+ end
46
+
47
+ def hint(harness, reason = "")
48
+ @prompt.say("#{harness.name}: #{reason}Log in in the VM: #{harness.vm_login_hint}")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ class Vm
5
+ VmOptions = Struct.new(:cpus, :memory, :disk, :gem, :login, keyword_init: true) do
6
+ def self.parse(argv, root)
7
+ options = new(cpus: 4, memory: 4, disk: 20, gem: "maf", login: $stdin.tty? ? :ask : :none)
8
+ parse!(options, argv)
9
+ options.gem = copy_gem(options.gem, root) if options.gem.end_with?(".gem")
10
+ options
11
+ end
12
+
13
+ def self.parse!(options, argv)
14
+ parser(options).parse!(argv.dup)
15
+ rescue OptionParser::ParseError => e
16
+ abort "maf: #{e.message}\n#{USAGE}"
17
+ end
18
+
19
+ def self.parser(options)
20
+ OptionParser.new(USAGE) do |o|
21
+ %i[cpus memory disk].each { |key| o.on("--#{key} N", Integer) { |value| options[key] = positive(value) } }
22
+ o.on("--gem FILE") { |file| options.gem = file }
23
+ o.on("--[no-]copy-logins") { |copy| options.login = copy ? :all : :none }
24
+ end
25
+ end
26
+
27
+ def self.positive(value)
28
+ value.positive? ? value : raise(OptionParser::InvalidArgument, "#{value} (use a number above 0)")
29
+ end
30
+
31
+ def self.copy_gem(file, root)
32
+ abort "maf: #{file} missing." unless File.exist?(file)
33
+
34
+ dest = File.join(root, ".maf", "vm", File.basename(file))
35
+ FileUtils.mkdir_p(File.dirname(dest))
36
+ FileUtils.cp(file, dest)
37
+ dest
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "shellwords"
5
+
6
+ module Maf
7
+ class Vm
8
+ # Template renders assets/lima/maf.yaml.erb for one project.
9
+ class Template
10
+ FILE = File.join(Flow::ROOT, "assets", "lima", "maf.yaml.erb")
11
+ TASKWARRIOR = "3.5.0"
12
+ # The VM checks the download. Change the checksum with the version.
13
+ TASKWARRIOR_SHA256 = "9ea64b411f8314414f440ec765dfdf5a86c9f6159df47e2f60cff3db6b31157a"
14
+ TASKWARRIOR_URL = "https://github.com/GothenburgBitFactory/taskwarrior/releases/download/" \
15
+ "v%<version>s/task-%<version>s.tar.gz"
16
+ HOSTS = { "darwin" => { vm_type: "vz", mount_type: "virtiofs" },
17
+ "linux" => { vm_type: "qemu", mount_type: "9p" } }.freeze
18
+
19
+ def initialize(root, options, harnesses)
20
+ @root = root
21
+ @options = options
22
+ @harnesses = harnesses
23
+ end
24
+
25
+ def render = ERB.new(File.read(FILE), trim_mode: "-").result_with_hash(**values)
26
+
27
+ def notes = @harnesses.reject(&:vm_install).map { |harness| "Install #{harness.name} in the VM yourself." }
28
+
29
+ private
30
+
31
+ def values
32
+ { project: @root, cpus: @options.cpus, memory: @options.memory, disk: @options.disk, gem_args: gem_args,
33
+ taskwarrior: TASKWARRIOR, taskwarrior_url: format(TASKWARRIOR_URL, version: TASKWARRIOR),
34
+ taskwarrior_sha256: TASKWARRIOR_SHA256,
35
+ harness_installs: @harnesses.select(&:vm_install).to_h { |h| [h.name, [h.command, h.vm_install]] },
36
+ **HOSTS.fetch(host) }
37
+ end
38
+
39
+ def gem_args = @options.gem == "maf" ? "maf -v #{Maf::VERSION.shellescape}" : @options.gem.shellescape
40
+
41
+ def host = RUBY_PLATFORM.include?("darwin") ? "darwin" : "linux"
42
+ end
43
+ end
44
+ end
data/lib/maf/vm.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # vm.rb - run the whole team in a Lima VM (https://lima-vm.io).
4
+ #
5
+ # maf vm create [--cpus N] [--memory GIB] [--disk GIB] [--gem FILE] [--[no-]copy-logins]
6
+ # maf vm login [--copy-logins]
7
+ # maf vm start | stop | delete | status
8
+ # maf vm shell [COMMAND ...]
9
+ #
10
+ # The VM mounts only the project folder, at the same path as on the host.
11
+ # The host is macOS (Apple Virtualization) or Linux (QEMU with KVM).
12
+ # Run maf, coord, and each agent in the VM. Read and review the work on the host.
13
+ require "digest"
14
+ require "erb"
15
+ require "fileutils"
16
+ require "json"
17
+ require "optparse"
18
+ require "tempfile"
19
+ require_relative "flow"
20
+ require_relative "shared/harness"
21
+
22
+ module Maf
23
+ # Vm runs limactl for the VM of the project.
24
+ class Vm
25
+ USAGE = "usage: maf vm create [--cpus N] [--memory GIB] [--disk GIB] [--gem FILE] [--[no-]copy-logins]" \
26
+ "|login|start|stop|delete|status|shell [COMMAND ...]"
27
+ ACTIONS = %w[create login start stop delete status shell].freeze
28
+ LIMACTL = "limactl"
29
+ INSTALL = "brew install lima (macOS), or Lima and QEMU from https://lima-vm.io/docs/installation/ (Linux)"
30
+
31
+ def initialize(argv, root)
32
+ @action, *@args = argv
33
+ @root = File.realpath(root)
34
+ end
35
+
36
+ def run
37
+ abort USAGE unless ACTIONS.include?(@action)
38
+ abort "maf: maf vm needs Lima. Install it: #{INSTALL}" unless Shared::Harness.on_path?(LIMACTL)
39
+
40
+ owned! unless @action == "create"
41
+ %w[start stop delete].include?(@action) ? lifecycle : send(@action)
42
+ end
43
+
44
+ def name = "maf-#{slug}-#{Digest::SHA256.hexdigest(@root)[0, 8]}"
45
+ def slug = File.basename(@root).downcase.gsub(/[^a-z0-9-]/, "-")[0, 30]
46
+
47
+ ENV_SCRIPT = '[ -f .maf/env.sh ] && . ./.maf/env.sh; export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH"; ' \
48
+ '[ $# -gt 0 ] || set -- bash -l; exec "$@"'
49
+
50
+ def shell_command = [LIMACTL, "shell", "--workdir", @root, name, "bash", "-c", ENV_SCRIPT, "bash", *@args]
51
+
52
+ private
53
+
54
+ def owned!
55
+ mounts = IO.popen([LIMACTL, "list", "--format", "{{json .Config.Mounts}}", name], err: File::NULL, &:read)
56
+ abort "maf: no VM for this project. Run: maf vm create" if mounts.strip.empty?
57
+ return if Array(JSON.parse(mounts)).any? { |mount| mount["location"] == @root }
58
+
59
+ abort "maf: the VM #{name} does not mount #{@root}. maf does not touch it."
60
+ end
61
+
62
+ def lifecycle
63
+ limactl(@action, *("--force" if @action == "delete"), name)
64
+ end
65
+
66
+ def create
67
+ options = VmOptions.parse(@args, @root)
68
+ template = Template.new(@root, options, harnesses)
69
+ Tempfile.create(["maf-vm", ".yaml"]) { |file| create_from(file.path, template.render) }
70
+ template.notes.each { |note| puts note }
71
+ Login.new(name, harnesses, options.login).run
72
+ end
73
+
74
+ def login = Login.new(name, harnesses, VmOptions.parse(@args, @root).login).run
75
+
76
+ def harnesses
77
+ ids = Shared::Project.manifest.fetch("agents", []).map { |agent| agent["harness"] }
78
+ ids.uniq.filter_map { |id| Shared::Harness[id] }
79
+ end
80
+
81
+ def create_from(path, yaml)
82
+ File.write(path, yaml)
83
+ limactl("create", "--tty=false", "--name=#{name}", path) && limactl("start", name)
84
+ end
85
+
86
+ def shell = Kernel.exec(*shell_command)
87
+ def status = limactl("list", name)
88
+ def limactl(*args) = system(LIMACTL, *args) || abort("maf: #{LIMACTL} #{args.first} failed.")
89
+ end
90
+ end
91
+
92
+ require_relative "vm/options"
93
+ require_relative "vm/template"
94
+ require_relative "vm/login"
@@ -26,7 +26,7 @@ module Maf
26
26
  @root = root
27
27
  @force = force
28
28
  @limits = limits
29
- @worker = Workers.id(spec)
29
+ @worker = resolve(Workers.at(root).all, Workers.id(spec))
30
30
  @entry = Workers.at(root).find(@worker) || abort("maf: no worker #{@worker}")
31
31
  end
32
32
 
@@ -39,11 +39,24 @@ module Maf
39
39
 
40
40
  private
41
41
 
42
+ def resolve(workers, worker)
43
+ return worker if workers.key?(worker)
44
+
45
+ only_worker(worker, workers.select { |_, entry| entry["role"] == worker }.keys) || worker
46
+ end
47
+
48
+ def only_worker(role, names)
49
+ abort "maf: role #{role} has more than one worker: #{names.join(", ")}. Name one." if names.size > 1
50
+ names.first
51
+ end
52
+
42
53
  def status = puts("#{@worker}: #{live? ? "running (pid #{pid})" : "stopped"}")
43
54
 
44
55
  def stop
45
56
  return puts("#{@worker} is already stopped.") unless live?
46
57
 
58
+ abort "maf: #{@worker}: maf cannot check pid #{pid}. Stop it with: kill #{pid}" unless ours?
59
+
47
60
  @entry["dispatch"] ? stop_dispatcher : stop_session
48
61
  end
49
62
 
@@ -84,13 +97,8 @@ module Maf
84
97
  def coord_dir = File.join(@root, ".maf", "coordination")
85
98
  def presence = read(File.join(coord_dir, "presence", "#{@worker}.json"))
86
99
  def pid = presence["pid"].to_i
87
- def live? = Shared::Processes.alive?(pid) && started_matches?
88
-
89
- # A pid can belong to a new process. The start time tells them apart.
90
- def started_matches?
91
- recorded = presence["started"].to_s
92
- recorded.empty? || recorded == Shared::Processes.started_at(pid)
93
- end
100
+ def live? = Shared::Processes.live?(presence)
101
+ def ours? = Shared::Processes.same?(pid, presence["started"])
94
102
 
95
103
  # The context-watch hook records the transcript of Claude Code and Codex
96
104
  # sessions. opencode has no such hook, so its idle state is unknown.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # worker_list.rb - list the workers that run now: sessions and dispatchers.
4
+ require "json"
5
+ require "time"
6
+ require_relative "workers"
7
+ require_relative "shared/processes"
8
+
9
+ module Maf
10
+ # WorkerList shows each running worker process of the project. A worker
11
+ # can run twice, for example as a session and as a dispatcher.
12
+ class WorkerList
13
+ Row = Struct.new(:worker, :role, :harness, :mode, :pid, :started)
14
+ HEADER = %w[WORKER ROLE HARNESS MODE PID STARTED].freeze
15
+ LSTART = "%a %b %d %H:%M:%S %Y %z"
16
+
17
+ def initialize(root)
18
+ @root = root
19
+ @workers = Workers.at(root)
20
+ end
21
+
22
+ def run
23
+ rows = running
24
+ puts rows.empty? ? "No worker runs." : table([HEADER] + rows.map { |row| row.to_a.map(&:to_s) }), warnings(rows)
25
+ end
26
+
27
+ private
28
+
29
+ def running = (sessions + dispatchers + registered).uniq(&:pid).sort_by { |row| [row.worker, row.mode] }
30
+
31
+ def coord_dir = File.join(@root, ".maf", "coordination")
32
+ def records(dir, pattern) = Dir.glob(File.join(coord_dir, dir, pattern)).map { |path| [path, read(path)] }
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
46
+
47
+ def dispatchers
48
+ records("presence", "*.json").filter_map do |_, entry|
49
+ row(entry, "dispatch") if entry["mode"] == "dispatch" && Shared::Processes.live?(entry)
50
+ end
51
+ end
52
+
53
+ def registered
54
+ @workers.all.filter_map do |worker, entry|
55
+ row(entry.merge("worker" => worker), "dispatch") if @workers.dispatcher?(worker)
56
+ end
57
+ end
58
+
59
+ def row(data, mode)
60
+ entry = @workers.find(data["worker"]).to_h
61
+ Row.new(data["worker"], data["role"] || entry["role"], data["harness"] || entry["harness"], mode,
62
+ data["pid"].to_i, Shared::Processes.started_at(data["pid"]))
63
+ end
64
+
65
+ def table(lines)
66
+ widths = lines.transpose.map { |column| column.map(&:size).max }
67
+ lines.map { |line| line.zip(widths).map { |text, width| text.ljust(width) }.join(" ").rstrip }
68
+ end
69
+
70
+ def warnings(rows)
71
+ twice = rows.group_by(&:worker).select { |_, list| list.size > 1 }
72
+ stopped = @workers.all.keys - rows.map(&:worker)
73
+ twice.map { |worker, list| "warning: #{worker} runs #{list.size} times (pids #{list.map(&:pid).join(", ")})" } +
74
+ (stopped.empty? ? [] : ["stopped: #{stopped.join(", ")}"])
75
+ end
76
+
77
+ def read(path)
78
+ JSON.parse(File.read(path))
79
+ rescue JSON::ParserError, SystemCallError
80
+ {}
81
+ end
82
+ end
83
+ end
data/lib/maf/workers.rb CHANGED
@@ -8,6 +8,7 @@
8
8
  require "json"
9
9
  require "fileutils"
10
10
  require "time"
11
+ require_relative "shared/processes"
11
12
 
12
13
  module Maf
13
14
  class Workers
@@ -27,8 +28,34 @@ module Maf
27
28
  def remove(worker) = write(all.except(worker))
28
29
  def update(worker, fields) = add(worker, find(worker).to_h.merge(fields))
29
30
 
31
+ def dispatcher?(worker)
32
+ pid = find(worker)&.fetch("pid", nil)
33
+ pid ? Shared::Processes.same?(pid, started(worker)) : false
34
+ end
35
+
36
+ def started(worker)
37
+ entry = find(worker).to_h
38
+ presence = presence(worker)
39
+ recorded = entry["started"].to_s
40
+ recorded.empty? && presence["pid"].to_i == entry["pid"].to_i ? presence["started"] : entry["started"]
41
+ end
42
+
43
+ def running?(worker)
44
+ return dispatcher?(worker) unless started(worker).to_s.empty?
45
+
46
+ presence(worker)["pid"].to_i == find(worker).to_h["pid"].to_i && Shared::Processes.live?(presence(worker))
47
+ end
48
+
49
+ def presence(worker) = read(File.join(File.dirname(@path), "presence", "#{worker}.json"))
50
+
30
51
  private
31
52
 
53
+ def read(path)
54
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
55
+ rescue JSON::ParserError
56
+ {}
57
+ end
58
+
32
59
  def write(data)
33
60
  FileUtils.mkdir_p(File.dirname(@path))
34
61
  File.write(@path, JSON.pretty_generate(data))
data/templates/roles.yml CHANGED
@@ -4,6 +4,9 @@
4
4
  # the model with --model ROLE=MODEL. If the user does not choose a model,
5
5
  # claude roles get lib/maf/flow.rb's DEFAULT_MODELS entry (claude-opus-5-5).
6
6
  #
7
+ # `effort` is the default effort level. A role gets it when the harness lists
8
+ # the level. --effort ROLE=LEVEL overrides it. A lower level uses fewer tokens.
9
+ #
7
10
  # `description` is also the routing line in the architect's "Available roles"
8
11
  # list. Keep it short and specific to the role.
9
12
  #
@@ -42,6 +45,7 @@ roles:
42
45
  - If a message starts with "ESCALATION", a role cannot fix a problem. Tell the user in plain words:
43
46
  what failed, which task, and the impact. Give two or three options with a recommended one.
44
47
  Wait for the answer. Then send the answer to the role with `coord msg`.
48
+ If the escalation names a blocked task, run `coord unblock <id>` after you send the answer.
45
49
  - If the architect escalates a repeated review failure, forward the
46
50
  options to the user. Do not send a new goal until the user decides.
47
51
 
@@ -53,6 +57,7 @@ roles:
53
57
  - Process detail in reports. The user needs outcomes and decisions.
54
58
  - Optimistic status. Report blockers and failed checks as they are.
55
59
  model_hint: "Strongest reasoning and long context, conversational. Cloud: Claude Opus 5.5 (default for the claude harness), GPT-5.x."
60
+ effort: medium
56
61
  can_edit: false
57
62
 
58
63
  architect:
@@ -84,6 +89,8 @@ roles:
84
89
  Goal, Inputs, Out of scope, Acceptance, Report format.
85
90
  A worker drifts if a field is missing.
86
91
  - Write acceptance criteria as exact test files or commands. The worker runs each one before coord done.
92
+ - A message `BLOCKED task <id> (dependency)` waits for other work. Plan or order that work.
93
+ When it lands, run `coord unblock <id>`. A blocked task stays out of `coord next` until then.
87
94
  - If a review rejects the same kind of problem two times, stop the fix
88
95
  loop. Send the project manager two or three options with the cost of
89
96
  each option. A third patch of the same kind usually fails too.
@@ -96,6 +103,7 @@ roles:
96
103
  - Code in task specs. Describe behavior and constraints instead.
97
104
  - Trust in a worker report without a diff check.
98
105
  model_hint: "Strongest reasoning and long context. Cloud: Claude Opus 5.5 (default for the claude harness), GPT-5.x, deepseek-v4."
106
+ effort: high
99
107
  can_edit: false
100
108
 
101
109
  backend-developer:
@@ -123,6 +131,7 @@ roles:
123
131
  - Edits to a test that looks wrong. Report the test to the architect.
124
132
  - Claims about code that you did not read.
125
133
  model_hint: "A strong code model. A local Qwen3.8-27B is enough for well-scoped tasks; use a cloud coder for hard or large changes."
134
+ effort: medium
126
135
  can_edit: true
127
136
 
128
137
  frontend-developer:
@@ -149,6 +158,7 @@ roles:
149
158
  - Values that are hard-coded to make a test pass.
150
159
  - Visual changes outside the task scope.
151
160
  model_hint: "Same as backend-developer: local coder for scoped work, cloud coder for hard UI work."
161
+ effort: medium
152
162
  can_edit: true
153
163
 
154
164
  tester:
@@ -174,6 +184,7 @@ roles:
174
184
  - Assertions that only repeat the implementation.
175
185
  - Deleted or weakened tests. A test removal needs architect approval.
176
186
  model_hint: "Mechanical test work usually fits a local coder model; use cloud for subtle failure analysis."
187
+ effort: low
177
188
  can_edit: true
178
189
 
179
190
  reviewer:
@@ -216,6 +227,7 @@ roles:
216
227
  - Style comments that the project's linter already covers.
217
228
  - A warning or minor finding labeled critical to force another round.
218
229
  model_hint: "Judgment-heavy. Prefer a cloud model with strong review ability."
230
+ effort: high
219
231
  can_edit: false
220
232
 
221
233
  skeptic:
@@ -252,6 +264,7 @@ roles:
252
264
  - A warning or minor finding labeled critical to force another round.
253
265
  - Style comments. The reviewer and the linter cover style.
254
266
  model_hint: "Judgment-heavy. Use a different model family from reviewer and auditor, for example GPT-5.x if reviewer runs Claude. Different models miss different defects."
267
+ effort: high
255
268
  can_edit: false
256
269
 
257
270
  auditor:
@@ -286,4 +299,5 @@ roles:
286
299
  - A warning or minor finding labeled critical to force another round.
287
300
  - Style comments. The reviewer and the linter cover style.
288
301
  model_hint: "Judgment-heavy. Use a different model family from reviewer and skeptic, for example Gemini, Grok, or a strong local model. Different models miss different defects."
302
+ effort: high
289
303
  can_edit: false
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dominik Alberski
@@ -24,9 +24,9 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.8'
26
26
  description: multi-agent-flow is a coordination layer for AI coding agents (Claude
27
- Code, Codex, opencode, Hermes). Each agent works in its own git worktree. The agents
28
- share a Taskwarrior task board, file locks, and a knowledge graph through the coord
29
- CLI.
27
+ Code, Codex, opencode, Cursor, Hermes). Each agent works in its own git worktree.
28
+ The agents share a Taskwarrior task board, file locks, and a knowledge graph through
29
+ the coord CLI.
30
30
  executables:
31
31
  - maf
32
32
  extensions: []
@@ -52,6 +52,7 @@ files:
52
52
  - assets/harness-hooks/next-task-hermes.sh
53
53
  - assets/harness-hooks/next-task.rb
54
54
  - assets/harness-hooks/session-guard.rb
55
+ - assets/lima/maf.yaml.erb
55
56
  - assets/taskrc.append
56
57
  - assets/vault
57
58
  - assets/worktree-env.example.rb
@@ -82,8 +83,10 @@ files:
82
83
  - lib/maf/flow/agent_links.rb
83
84
  - lib/maf/flow/bootstrapper.rb
84
85
  - lib/maf/flow/codex_hooks.rb
86
+ - lib/maf/flow/effort_report.rb
85
87
  - lib/maf/flow/generator.rb
86
88
  - lib/maf/flow/harness_linker.rb
89
+ - lib/maf/flow/hermes_env_setup.rb
87
90
  - lib/maf/flow/hermes_hook.rb
88
91
  - lib/maf/flow/hermes_hook_setup.rb
89
92
  - lib/maf/flow/hook_files.rb
@@ -117,20 +120,37 @@ files:
117
120
  - lib/maf/setup_agent.rb
118
121
  - lib/maf/setup_agent/args.rb
119
122
  - lib/maf/setup_agent/dispatch.rb
120
- - lib/maf/setup_agent/hermes_launcher.rb
121
- - lib/maf/setup_agent/hermes_skill.rb
122
123
  - lib/maf/setup_agent/launcher.rb
123
124
  - lib/maf/setup_agent/manifest.rb
124
125
  - lib/maf/setup_agent/project.rb
126
+ - lib/maf/setup_agent/pty_relay.rb
125
127
  - lib/maf/setup_agent/role_file.rb
126
128
  - lib/maf/setup_agent/runtime_hooks.rb
127
129
  - lib/maf/setup_agent/worktree.rb
128
130
  - lib/maf/shared/git_exclude.rb
129
131
  - lib/maf/shared/git_identity.rb
132
+ - lib/maf/shared/harness.rb
133
+ - lib/maf/shared/harness/base.rb
134
+ - lib/maf/shared/harness/defaults.rb
135
+ - lib/maf/shared/harness/output.rb
136
+ - lib/maf/shared/harness/traits.rb
137
+ - lib/maf/shared/harness/vm_credential.rb
138
+ - lib/maf/shared/harnesses/claude.rb
139
+ - lib/maf/shared/harnesses/codex.rb
140
+ - lib/maf/shared/harnesses/cursor.rb
141
+ - lib/maf/shared/harnesses/hermes.rb
142
+ - lib/maf/shared/harnesses/opencode.rb
130
143
  - lib/maf/shared/peak_rate.rb
131
144
  - lib/maf/shared/processes.rb
132
145
  - lib/maf/shared/project.rb
133
146
  - lib/maf/shared/roles.rb
147
+ - lib/maf/shared/sandbox.rb
148
+ - lib/maf/shared/sandbox/bwrap.rb
149
+ - lib/maf/shared/sandbox/harness_files.rb
150
+ - lib/maf/shared/sandbox/links.rb
151
+ - lib/maf/shared/sandbox/paths.rb
152
+ - lib/maf/shared/sandbox/references.rb
153
+ - lib/maf/shared/sandbox/seatbelt.rb
134
154
  - lib/maf/team.rb
135
155
  - lib/maf/team_command.rb
136
156
  - lib/maf/uninstall.rb
@@ -150,16 +170,18 @@ files:
150
170
  - lib/maf/uninstall/runner.rb
151
171
  - lib/maf/uninstall/scripts.rb
152
172
  - lib/maf/uninstall/vault_watcher.rb
173
+ - lib/maf/uninstall/vm_files.rb
153
174
  - lib/maf/uninstall/worktrees.rb
154
175
  - lib/maf/untrack.rb
155
176
  - lib/maf/version.rb
177
+ - lib/maf/vm.rb
178
+ - lib/maf/vm/login.rb
179
+ - lib/maf/vm/options.rb
180
+ - lib/maf/vm/template.rb
156
181
  - lib/maf/worker_archive.rb
157
182
  - lib/maf/worker_control.rb
183
+ - lib/maf/worker_list.rb
158
184
  - lib/maf/workers.rb
159
- - templates/claude.md.erb
160
- - templates/codex.md.erb
161
- - templates/hermes.md.erb
162
- - templates/opencode.md.erb
163
185
  - templates/role-stub.yml.erb
164
186
  - templates/roles.yml
165
187
  - templates/workflows/panel.md
@@ -187,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
187
209
  - !ruby/object:Gem::Version
188
210
  version: '0'
189
211
  requirements: []
190
- rubygems_version: 4.0.22
212
+ rubygems_version: 4.0.20
191
213
  specification_version: 4
192
214
  summary: Run several AI coding agents in parallel on one repository.
193
215
  test_files: []