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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +174 -0
- data/README.md +29 -11
- data/assets/agents-contract.md +2 -0
- data/assets/analyst +29 -51
- data/assets/coord +168 -41
- data/assets/dashboard +1 -5
- data/assets/dispatcher +127 -298
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/install.md +6 -1
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/cli.rb +22 -9
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +3 -2
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +3 -3
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +14 -38
- data/lib/maf/flow/options.rb +33 -5
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +13 -14
- data/lib/maf/flow/report.rb +8 -9
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +7 -22
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +7 -21
- data/lib/maf/menu.rb +49 -15
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +3 -3
- data/lib/maf/setup_agent/launcher.rb +16 -65
- data/lib/maf/setup_agent/manifest.rb +9 -3
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +11 -13
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +147 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +137 -0
- data/lib/maf/shared/harnesses/codex.rb +202 -0
- data/lib/maf/shared/harnesses/cursor.rb +120 -0
- data/lib/maf/shared/harnesses/hermes.rb +122 -0
- data/lib/maf/shared/harnesses/opencode.rb +192 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/roles.rb +4 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team.rb +5 -4
- data/lib/maf/team_command.rb +5 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +12 -25
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +16 -8
- data/lib/maf/worker_list.rb +83 -0
- data/lib/maf/workers.rb +27 -0
- data/templates/roles.yml +14 -0
- metadata +33 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- 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),
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
data/lib/maf/vm/login.rb
ADDED
|
@@ -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"
|
data/lib/maf/worker_control.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
28
|
-
share a Taskwarrior task board, file locks, and a knowledge graph through
|
|
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.
|
|
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: []
|