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/retire.rb
CHANGED
|
@@ -7,7 +7,12 @@ require_relative "shared/processes"
|
|
|
7
7
|
|
|
8
8
|
module Maf
|
|
9
9
|
class Retire
|
|
10
|
-
|
|
10
|
+
NOT_OURS = "maf: pid %<pid>s is not the dispatcher of %<worker>s: another process has the pid now. " \
|
|
11
|
+
"maf does not stop it."
|
|
12
|
+
DISPATCHER_COMMAND = %r{(\A|[\s/])\.maf/bin/dispatcher(\s|\z)}
|
|
13
|
+
|
|
14
|
+
# Changes in these folders belong to the flow, not to the work of the worker.
|
|
15
|
+
def self.runtime_prefixes = [".maf/"] + Maf::Shared::Harness.all.filter_map(&:home_dir).map { |dir| "#{dir}/" }
|
|
11
16
|
|
|
12
17
|
def initialize(root, spec)
|
|
13
18
|
@root = root
|
|
@@ -38,12 +43,42 @@ module Maf
|
|
|
38
43
|
def stop_dispatcher
|
|
39
44
|
pid = Workers.at(@root).find(@worker)&.fetch("pid", nil)
|
|
40
45
|
return unless pid && Shared::Processes.alive?(pid)
|
|
46
|
+
return warn(format(NOT_OURS, pid: pid, worker: @worker)) unless dispatcher?(pid)
|
|
41
47
|
|
|
42
48
|
puts "Stopping the dispatcher of #{@worker} (pid #{pid}). A running agent finishes first."
|
|
43
49
|
RunningProcesses.terminate(pid)
|
|
44
50
|
abort "maf: pid #{pid} did not stop. Stop it with: kill #{pid}" unless RunningProcesses.wait_for_exit(pid)
|
|
45
51
|
end
|
|
46
52
|
|
|
53
|
+
def dispatcher?(pid)
|
|
54
|
+
workers = Workers.at(@root)
|
|
55
|
+
return workers.dispatcher?(@worker) unless workers.started(@worker).to_s.empty?
|
|
56
|
+
return true if legacy_dispatcher?(pid)
|
|
57
|
+
|
|
58
|
+
abort "maf: maf cannot check pid #{pid}. Stop it with: kill #{pid}, then run this again." if beating?(pid)
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def legacy_dispatcher?(pid)
|
|
63
|
+
command = IO.popen(["ps", "-o", "command=", "-p", pid.to_s], err: File::NULL, &:read).to_s.strip
|
|
64
|
+
command.match?(DISPATCHER_COMMAND) && RunningProcesses.in(@dir).include?(pid.to_i)
|
|
65
|
+
rescue SystemCallError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def beating?(pid)
|
|
70
|
+
presence = read_presence
|
|
71
|
+
presence["pid"].to_i == pid.to_i && Shared::Processes.recent?(presence["seen_at"])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_presence
|
|
75
|
+
JSON.parse(File.read(presence_path))
|
|
76
|
+
rescue SystemCallError, JSON::ParserError
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def presence_path = File.join(@root, ".maf", "coordination", "presence", "#{@worker}.json")
|
|
81
|
+
|
|
47
82
|
def release_tasks
|
|
48
83
|
env = { "COORD_ROLE" => role, "COORD_WORKER" => @worker }
|
|
49
84
|
ids = Team.coord(@root, "next", "--mine", env: env).scan(/^[0-9a-f-]{36}/)
|
|
@@ -56,10 +91,7 @@ module Maf
|
|
|
56
91
|
Team.coord(@root, "annotate", id, "Worker #{@worker} was retired. Continue on the existing task branch.")
|
|
57
92
|
end
|
|
58
93
|
|
|
59
|
-
def remove_presence
|
|
60
|
-
coord_dir = File.join(@root, ".maf", "coordination")
|
|
61
|
-
FileUtils.rm_f(File.join(coord_dir, "presence", "#{@worker}.json"))
|
|
62
|
-
end
|
|
94
|
+
def remove_presence = FileUtils.rm_f(presence_path)
|
|
63
95
|
|
|
64
96
|
def remove_worker
|
|
65
97
|
remove_worktree
|
|
@@ -72,7 +104,11 @@ module Maf
|
|
|
72
104
|
return false unless Dir.exist?(@dir)
|
|
73
105
|
|
|
74
106
|
lines = IO.popen(["git", "-C", @dir, "status", "--porcelain"], err: File::NULL, &:readlines)
|
|
75
|
-
lines.map { |line| line[3..].strip }.any?
|
|
107
|
+
lines.map { |line| line[3..].strip }.any? do |path|
|
|
108
|
+
Retire.runtime_prefixes.none? do |prefix|
|
|
109
|
+
path.start_with?(prefix)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
76
112
|
end
|
|
77
113
|
|
|
78
114
|
def remove_worktree
|
data/lib/maf/setup_agent/args.rb
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
module Maf
|
|
4
4
|
module SetupAgent
|
|
5
5
|
# Args reads the positional arguments (HARNESS ROLE[_WORKER] [model:M]),
|
|
6
|
-
# then the flags. --dispatch
|
|
7
|
-
# other flags go to the dispatcher and need --dispatch.
|
|
6
|
+
# then the flags. --dispatch, --model M, and --effort L are setup_agent's own
|
|
7
|
+
# flags; all other flags go to the dispatcher and need --dispatch.
|
|
8
8
|
class Args
|
|
9
|
-
Parsed = Struct.new(:harness, :role, :worker_id, :worker, :model, :
|
|
10
|
-
keyword_init: true)
|
|
9
|
+
Parsed = Struct.new(:harness, :role, :worker_id, :worker, :model, :effort, :dispatch, :detach,
|
|
10
|
+
:dispatcher_args, keyword_init: true)
|
|
11
11
|
|
|
12
12
|
def self.parse(argv)
|
|
13
13
|
dispatch, detach, positional, flags = split(argv)
|
|
14
|
-
model =
|
|
14
|
+
model, effort = %w[--model --effort].map { |flag| take_value(flags, flag) }
|
|
15
|
+
check_flags(flags, dispatch, detach)
|
|
16
|
+
Parsed.new(**identity(positional, model, dispatch), effort: effort, dispatch: dispatch, detach: detach,
|
|
17
|
+
dispatcher_args: flags)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.check_flags(flags, dispatch, detach)
|
|
15
21
|
reject_stray(flags, dispatch)
|
|
16
22
|
abort "setup_agent: --detach works only with --dispatch" if detach && !dispatch
|
|
17
|
-
Parsed.new(**identity(positional, model, dispatch), dispatch: dispatch, detach: detach, dispatcher_args: flags)
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def self.split(argv)
|
|
@@ -31,10 +36,10 @@ module Maf
|
|
|
31
36
|
model: model || model_arg&.sub(/\Amodel:/, "") }
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def self.
|
|
35
|
-
index = flags.index(
|
|
39
|
+
def self.take_value(flags, flag)
|
|
40
|
+
index = flags.index(flag)
|
|
36
41
|
value = index && flags.slice!(index, 2)[1]
|
|
37
|
-
abort "setup_agent:
|
|
42
|
+
abort "setup_agent: #{flag} needs a value" if index && (value.nil? || value.start_with?("--"))
|
|
38
43
|
value
|
|
39
44
|
end
|
|
40
45
|
|
|
@@ -50,7 +55,8 @@ module Maf
|
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
def self.abort_usage
|
|
53
|
-
abort "usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--
|
|
58
|
+
abort "usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--effort LEVEL] " \
|
|
59
|
+
"[--dispatch [FLAGS...]]"
|
|
54
60
|
end
|
|
55
61
|
end
|
|
56
62
|
end
|
|
@@ -9,11 +9,11 @@ module Maf
|
|
|
9
9
|
module Dispatch
|
|
10
10
|
DISPATCHER = ".maf/bin/dispatcher"
|
|
11
11
|
|
|
12
|
-
def self.launch(args
|
|
12
|
+
def self.launch(args)
|
|
13
13
|
dispatcher = File.join(Project.root, DISPATCHER)
|
|
14
14
|
require_dispatcher!(dispatcher)
|
|
15
15
|
cmd = [RbConfig.ruby, dispatcher, args.role, "--harness", args.harness]
|
|
16
|
-
cmd +=
|
|
16
|
+
cmd += { "--model" => args.model, "--effort" => args.effort }.compact.flatten
|
|
17
17
|
cmd += args.dispatcher_args
|
|
18
18
|
args.detach ? detach(cmd, args.worker) : Launcher.exec_or_die(cmd)
|
|
19
19
|
end
|
|
@@ -23,7 +23,7 @@ module Maf
|
|
|
23
23
|
def self.detach(cmd, worker)
|
|
24
24
|
log = File.join(ENV.fetch("COORD_DIR"), "sessions", "#{worker}.log")
|
|
25
25
|
pid = spawn_detached(cmd, log)
|
|
26
|
-
Maf::Workers.at(Project.root).update(worker, "pid" => pid)
|
|
26
|
+
Maf::Workers.at(Project.root).update(worker, "pid" => pid, "started" => Maf::Shared::Processes.started_at(pid))
|
|
27
27
|
puts "Started #{worker} in the background (pid #{pid}). Log: #{log}"
|
|
28
28
|
end
|
|
29
29
|
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../shared/sandbox"
|
|
4
|
+
require_relative "pty_relay"
|
|
5
|
+
|
|
3
6
|
module Maf
|
|
4
7
|
module SetupAgent
|
|
8
|
+
# Launcher replaces this process with the interactive session of a harness.
|
|
9
|
+
# The harness file builds the command (Maf::Shared::Harnesses).
|
|
5
10
|
module Launcher
|
|
6
|
-
|
|
11
|
+
def self.launch(harness, role, model, effort = nil)
|
|
12
|
+
ENV.update(harness.launch_env)
|
|
13
|
+
return exec_or_die(harness.launch(role, model, effort)) if Maf::Shared::Sandbox.mode == "off"
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
REGISTRY.fetch(harness) { abort "setup_agent: no launcher for harness '#{harness}' yet." }
|
|
15
|
+
warn "maf: sandbox: #{Maf::Shared::Sandbox.describe(harness)}"
|
|
16
|
+
exit Maf::Shared::Sandbox.keeping_links(harness) { relay_or_die(sandboxed(harness, role, model, effort)) }
|
|
11
17
|
end
|
|
12
18
|
|
|
13
|
-
def self.
|
|
14
|
-
|
|
15
|
-
REGISTRY["opencode"] = Opencode
|
|
16
|
-
REGISTRY["codex"] = Codex
|
|
17
|
-
REGISTRY["hermes"] = Hermes
|
|
19
|
+
def self.sandboxed(harness, role, model, effort)
|
|
20
|
+
Maf::Shared::Sandbox.wrap(harness.launch(role, model, effort), harness)
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def self.exec_or_die(cmd)
|
|
@@ -23,62 +26,10 @@ module Maf
|
|
|
23
26
|
abort "setup_agent: '#{cmd.first}' not found on PATH."
|
|
24
27
|
end
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def self.path(*parts) = File.join(Project.root, ".maf", *parts)
|
|
31
|
-
def self.flag(name, *parts) = File.exist?(path(*parts)) ? [name, path(*parts)] : []
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# --settings adds the hooks of the flow, --mcp-config adds the graph server.
|
|
35
|
-
# --mcp-config takes many values, so `--` ends the options before the prompt.
|
|
36
|
-
module Claude
|
|
37
|
-
def self.launch(role, _worker, model)
|
|
38
|
-
prompt = "Read .maf/agents/claude/#{role}.md and follow it exactly. Start your work loop now."
|
|
39
|
-
cmd = ["claude", *FlowFiles.flag("--settings", "claude", "settings.json"),
|
|
40
|
-
*FlowFiles.flag("--mcp-config", "mcp", "claude.json")]
|
|
41
|
-
Launcher.exec_or_die(cmd + (model ? ["--model", model] : []) + ["--", prompt])
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# opencode merges the file in OPENCODE_CONFIG with the project's opencode.json.
|
|
46
|
-
module Opencode
|
|
47
|
-
def self.launch(role, _worker, model)
|
|
48
|
-
config = FlowFiles.path("mcp", "opencode.json")
|
|
49
|
-
ENV["OPENCODE_CONFIG"] = config if File.exist?(config)
|
|
50
|
-
cmd = ["opencode", ".", "--agent", role, "--prompt", "Start your work loop now."]
|
|
51
|
-
Launcher.exec_or_die(cmd + (model ? ["--model", model] : []))
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
module Codex
|
|
56
|
-
def self.launch(role, _worker, model)
|
|
57
|
-
prompt_file = ".codex/prompts/#{role}.md"
|
|
58
|
-
abort "setup_agent: #{prompt_file} missing." unless File.exist?(prompt_file)
|
|
59
|
-
|
|
60
|
-
cmd = ["codex", *writable_dirs]
|
|
61
|
-
cmd += ["--model", model] if model
|
|
62
|
-
warn_untrusted
|
|
63
|
-
Launcher.exec_or_die(cmd + [File.read(prompt_file)])
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
UNTRUSTED = "maf: Codex does not trust the hooks in %<file>s yet. Trust them when Codex asks. " \
|
|
67
|
-
"Without the hooks, the session records no token usage, and coord await cannot wake it."
|
|
68
|
-
|
|
69
|
-
# Codex runs the hooks of a project only after the user trusts them, and
|
|
70
|
-
# a worktree is a new project for Codex. Codex records the trust in its config.toml.
|
|
71
|
-
def self.warn_untrusted
|
|
72
|
-
hooks = File.join(File.realpath(Dir.pwd), ".codex", "hooks.json")
|
|
73
|
-
config = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
|
|
74
|
-
trusted = File.exist?(config) && File.read(config).include?("#{hooks}:stop:")
|
|
75
|
-
warn format(UNTRUSTED, file: hooks) if File.exist?(hooks) && !trusted
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# The worktree is the only writable root of the Codex sandbox. coord
|
|
79
|
-
# writes the board and the artifacts in .maf/coordination of the main
|
|
80
|
-
# project, so Codex would ask for approval at each coord command.
|
|
81
|
-
def self.writable_dirs = ["--add-dir", File.join(SetupAgent::Project.root, ".maf", "coordination")]
|
|
29
|
+
def self.relay_or_die(cmd)
|
|
30
|
+
PtyRelay.new(cmd).run
|
|
31
|
+
rescue Errno::ENOENT
|
|
32
|
+
abort "setup_agent: '#{cmd.first}' not found on PATH."
|
|
82
33
|
end
|
|
83
34
|
end
|
|
84
35
|
end
|
|
@@ -26,10 +26,16 @@ module Maf
|
|
|
26
26
|
# maf add keeps the current agents, so the hint names only the missing agent.
|
|
27
27
|
def add_hint(missing) = " Fix: maf add #{missing}"
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
# The saved model and effort of the agent, unless args already name them.
|
|
30
|
+
def fill(args)
|
|
31
|
+
verify!(args.harness, args.role)
|
|
32
|
+
saved = entry(args.harness, args.role)
|
|
33
|
+
args.model ||= saved["model"]
|
|
34
|
+
args.effort ||= saved["effort"]
|
|
35
|
+
args
|
|
32
36
|
end
|
|
37
|
+
|
|
38
|
+
def entry(harness, role) = @agents.find { |a| a["harness"] == harness && a["role"] == role }
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pty"
|
|
4
|
+
require "io/console"
|
|
5
|
+
|
|
6
|
+
module Maf
|
|
7
|
+
module SetupAgent
|
|
8
|
+
# PtyRelay runs a command on its own pseudo terminal and copies the input and the output.
|
|
9
|
+
class PtyRelay
|
|
10
|
+
CHUNK = 4096
|
|
11
|
+
DRAIN = 0.5
|
|
12
|
+
SIGNALS = %w[TERM HUP INT].freeze
|
|
13
|
+
EOF = "\x04"
|
|
14
|
+
|
|
15
|
+
def initialize(cmd) = @cmd = cmd
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
output, input, pid = PTY.spawn(*@cmd)
|
|
19
|
+
SIGNALS.each { |name| Signal.trap(name) { pass(name, pid) } }
|
|
20
|
+
relay(output, input, Process.detach(pid))
|
|
21
|
+
ensure
|
|
22
|
+
[output, input].compact.each(&:close)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def relay(output, input, waiter)
|
|
28
|
+
resize(input) && Signal.trap("WINCH") { resize(input) }
|
|
29
|
+
copiers = [Thread.new { copy_input(input) }, Thread.new { copy(output, $stdout) }]
|
|
30
|
+
raw { waiter.join }
|
|
31
|
+
stop(copiers)
|
|
32
|
+
waiter.value.exitstatus || 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stop(copiers)
|
|
36
|
+
copiers.last.join(DRAIN)
|
|
37
|
+
copiers.each(&:kill)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def copy(from, to)
|
|
41
|
+
loop { to.write(from.readpartial(CHUNK)) && to.flush }
|
|
42
|
+
rescue IOError, Errno::EIO
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The end of the input is no end on a terminal, so the command gets ^D. Else `echo task | maf start` waits.
|
|
47
|
+
# After a line without a newline, the first ^D only sends the line, so the command gets a second one.
|
|
48
|
+
def copy_input(input)
|
|
49
|
+
last = send_input(input)
|
|
50
|
+
input.write(last.end_with?("\n") ? EOF : EOF * 2)
|
|
51
|
+
rescue IOError, Errno::EIO
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def send_input(input)
|
|
56
|
+
last = "\n"
|
|
57
|
+
loop { input.write(last = $stdin.readpartial(CHUNK)) && input.flush }
|
|
58
|
+
rescue EOFError
|
|
59
|
+
last
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pass(name, pid)
|
|
63
|
+
Process.kill(name, pid)
|
|
64
|
+
rescue Errno::ESRCH
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def raw(&block) = $stdin.tty? ? $stdin.raw(&block) : yield
|
|
69
|
+
|
|
70
|
+
def resize(input)
|
|
71
|
+
$stdout.tty? && (input.winsize = $stdout.winsize)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -8,7 +8,7 @@ module Maf
|
|
|
8
8
|
# A harness folder with the project's own files gets one link per role file.
|
|
9
9
|
module RoleFile
|
|
10
10
|
def self.copy(root, dir, harness, role)
|
|
11
|
-
relative =
|
|
11
|
+
relative = Maf::Shared::Harness.fetch(harness).role_path(role)
|
|
12
12
|
return unless relative
|
|
13
13
|
|
|
14
14
|
copy_missing(File.join(root, relative), File.join(dir, relative))
|
|
@@ -8,18 +8,7 @@ module Maf
|
|
|
8
8
|
module RuntimeHooks
|
|
9
9
|
def self.install(dir, harness)
|
|
10
10
|
%w[next-task.rb board-watch.rb session-guard.rb context-watch.rb].each { |name| copy(dir, name) }
|
|
11
|
-
|
|
12
|
-
copy(dir, "board-watch-opencode.js", ".opencode/plugins/board-watch.js") if harness == "opencode"
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# Claude Code reads the hooks from the settings file of the main project
|
|
16
|
-
# (`maf start` passes it with --settings). The hook commands run in the
|
|
17
|
-
# worktree, where the copies above live.
|
|
18
|
-
def self.configure(dir, harness)
|
|
19
|
-
root = Project.root
|
|
20
|
-
settings = File.join(root, Bootstrap::CLAUDE_SETTINGS)
|
|
21
|
-
Bootstrap::ClaudeSettings.new(Bootstrap::Project.new(root, false)).configure(settings) if harness == "claude"
|
|
22
|
-
Flow::CodexHooks.new(dir).install if harness == "codex"
|
|
11
|
+
Maf::Shared::Harness.fetch(harness).runtime_hooks(dir)
|
|
23
12
|
end
|
|
24
13
|
|
|
25
14
|
def self.copy(dir, name, relative = ".maf/coordination/harness-hooks/#{name}")
|
data/lib/maf/setup_agent.rb
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# setup_agent.rb - create a worktree for one agent and launch its harness session.
|
|
4
4
|
#
|
|
5
|
-
# Usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL]
|
|
5
|
+
# Usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--effort LEVEL]
|
|
6
6
|
# [--dispatch [DISPATCHER FLAGS...]]
|
|
7
7
|
# maf start claude backend-developer_1
|
|
8
8
|
# maf start opencode reviewer --model openrouter/deepseek-v3
|
|
9
|
+
# maf start claude architect --effort high
|
|
9
10
|
# maf start hermes tester --dispatch --model openrouter/deepseek-v3
|
|
10
11
|
# maf start claude reviewer --dispatch --cache-window 1500
|
|
11
12
|
#
|
|
@@ -36,11 +37,10 @@ module Maf
|
|
|
36
37
|
MANIFEST = ".maf/config.json"
|
|
37
38
|
|
|
38
39
|
def self.run(argv)
|
|
39
|
-
args = Args.parse(argv)
|
|
40
|
-
manifest = Manifest.load(MANIFEST).tap { |m| m.verify!(args.harness, args.role) }
|
|
40
|
+
args = Manifest.load(MANIFEST).fill(Args.parse(argv))
|
|
41
41
|
worktree = enter_worktree(args)
|
|
42
|
-
register(args
|
|
43
|
-
launch(args,
|
|
42
|
+
register(args)
|
|
43
|
+
launch(args, worktree)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# `maf start` without arguments, inside a worktree that `maf prepare` made.
|
|
@@ -56,12 +56,13 @@ module Maf
|
|
|
56
56
|
def self.start_args(entry)
|
|
57
57
|
args = [entry["harness"], "#{entry["role"]}_#{entry["worker_id"]}"]
|
|
58
58
|
args += ["--model", entry["model"]] if entry["model"]
|
|
59
|
+
args += ["--effort", entry["effort"]] if entry["effort"]
|
|
59
60
|
entry["dispatch"] ? args + ["--dispatch"] : args
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
def self.register(args
|
|
63
|
+
def self.register(args)
|
|
63
64
|
entry = { "role" => args.role, "worker_id" => args.worker_id, "harness" => args.harness,
|
|
64
|
-
"model" => args.model
|
|
65
|
+
"model" => args.model, "effort" => args.effort, "dispatch" => args.dispatch, "dir" => Dir.pwd }
|
|
65
66
|
Maf::Workers.at(Project.root).add(args.worker, entry.compact)
|
|
66
67
|
end
|
|
67
68
|
|
|
@@ -79,13 +80,12 @@ module Maf
|
|
|
79
80
|
worktree
|
|
80
81
|
end
|
|
81
82
|
|
|
82
|
-
def self.launch(args,
|
|
83
|
+
def self.launch(args, _worktree)
|
|
83
84
|
identify(args)
|
|
84
|
-
|
|
85
|
-
return Dispatch.launch(args, model) if args.dispatch
|
|
85
|
+
return Dispatch.launch(args) if args.dispatch
|
|
86
86
|
|
|
87
87
|
MafSession.register(Project.root, args.harness)
|
|
88
|
-
Launcher.
|
|
88
|
+
Launcher.launch(Maf::Shared::Harness.fetch(args.harness), args.role, args.model, args.effort)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
# The session and the dispatcher pass the environment to each child, so
|
|
@@ -104,8 +104,6 @@ require_relative "setup_agent/role_file"
|
|
|
104
104
|
require_relative "setup_agent/manifest"
|
|
105
105
|
require_relative "setup_agent/worktree"
|
|
106
106
|
require_relative "setup_agent/launcher"
|
|
107
|
-
require_relative "setup_agent/hermes_launcher"
|
|
108
107
|
require_relative "setup_agent/project"
|
|
109
|
-
require_relative "setup_agent/hermes_skill"
|
|
110
108
|
require_relative "setup_agent/runtime_hooks"
|
|
111
109
|
require_relative "../../assets/harness-hooks/session-guard"
|
|
@@ -0,0 +1,57 @@
|
|
|
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 "erb"
|
|
7
|
+
require "json"
|
|
8
|
+
require_relative "defaults"
|
|
9
|
+
require_relative "output"
|
|
10
|
+
|
|
11
|
+
module Maf
|
|
12
|
+
module Shared
|
|
13
|
+
# Harness is the registry of the agent harnesses. Each file in harnesses/
|
|
14
|
+
# defines one harness: a subclass of Base. The rest of maf asks the
|
|
15
|
+
# registry and never names a harness.
|
|
16
|
+
module Harness
|
|
17
|
+
def self.register(klass) = (@classes ||= []) << klass
|
|
18
|
+
def self.all = @all ||= @classes.map(&:new).sort_by(&:name)
|
|
19
|
+
def self.names = all.map(&:name)
|
|
20
|
+
def self.[](name) = all.find { |harness| harness.name == name }
|
|
21
|
+
def self.fetch(name) = self[name] || raise(KeyError, "unknown harness '#{name}' (use #{names.join(", ")})")
|
|
22
|
+
def self.known?(name) = !self[name].nil?
|
|
23
|
+
|
|
24
|
+
def self.on_path?(command)
|
|
25
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Base is the interface of a harness. A harness file overrides the
|
|
29
|
+
# methods that its CLI needs. Every other method keeps the default here.
|
|
30
|
+
# The methods are grouped by the part of maf that calls them.
|
|
31
|
+
class Base
|
|
32
|
+
def self.inherited(klass)
|
|
33
|
+
super
|
|
34
|
+
Harness.register(klass)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The harness name in `maf add HARNESS:ROLE`. It comes from the class name.
|
|
38
|
+
def name = self.class.name.split("::").last.downcase
|
|
39
|
+
def to_s = name
|
|
40
|
+
|
|
41
|
+
# The CLI of the harness. `maf add` offers only the installed harnesses.
|
|
42
|
+
def command = name
|
|
43
|
+
def installed? = Harness.on_path?(command)
|
|
44
|
+
|
|
45
|
+
include RoleFiles
|
|
46
|
+
include Setup
|
|
47
|
+
include Run
|
|
48
|
+
|
|
49
|
+
# The tool calls of one session, for the analyst: [[name, command, size], ...].
|
|
50
|
+
def transcript_calls(_session_id) = []
|
|
51
|
+
|
|
52
|
+
# The JSON objects of a transcript file, one per line. A broken line is skipped.
|
|
53
|
+
def jsonl(path) = path ? File.foreach(path).filter_map { |line| Events.parse(line.strip) } : []
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
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 "../project"
|
|
7
|
+
require_relative "vm_credential"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
module Shared
|
|
11
|
+
module Harness
|
|
12
|
+
AGENTS_DIR = File.join(".maf", "agents")
|
|
13
|
+
# The command of the graphify MCP server. It runs in the project folder.
|
|
14
|
+
VAULT_MCP = %w[ruby .maf/bin/vault mcp].freeze
|
|
15
|
+
|
|
16
|
+
# RoleFiles: maf add writes one role file per agent.
|
|
17
|
+
module RoleFiles
|
|
18
|
+
# The folder where the harness reads role files. maf links it to
|
|
19
|
+
# .maf/agents/<name>/. nil: the harness reads no role folder.
|
|
20
|
+
def role_dir = nil
|
|
21
|
+
def role_path(role) = role_dir && File.join(AGENTS_DIR, name, "#{role}.md")
|
|
22
|
+
|
|
23
|
+
# settings: the data of .maf/config.json. A harness that keeps role
|
|
24
|
+
# files outside the project reads its folder there.
|
|
25
|
+
def role_destination(project, role, _settings = {}) = role_path(role) && File.join(project, role_path(role))
|
|
26
|
+
|
|
27
|
+
# An ERB template. It gets role, title, description, can_edit, project, prompt, model, and effort.
|
|
28
|
+
def role_template = raise(NotImplementedError, "#{name}: role_template")
|
|
29
|
+
def render_role(values) = ERB.new(role_template, trim_mode: "-").result_with_hash(effort: nil, **values)
|
|
30
|
+
|
|
31
|
+
# nil: the harness lists no models, so every name counts as known.
|
|
32
|
+
def models = nil
|
|
33
|
+
# list: the models of the harness. A caller with a cached list passes it.
|
|
34
|
+
def known_model?(model, list = models) = list.nil? || list.include?(model)
|
|
35
|
+
def default_model = nil
|
|
36
|
+
|
|
37
|
+
# The command that sets the model, for a harness whose role file cannot set it.
|
|
38
|
+
def model_hint(_model) = nil
|
|
39
|
+
|
|
40
|
+
# nil: the harness lists no effort levels, so every level counts as known.
|
|
41
|
+
def efforts(_model = nil) = nil
|
|
42
|
+
def known_effort?(effort, model = nil) = efforts(model).nil? || efforts(model).include?(effort)
|
|
43
|
+
# true: the model runs only with a level.
|
|
44
|
+
def effort_required?(_model) = false
|
|
45
|
+
|
|
46
|
+
# How an idle agent waits for work. :watch - a watcher wakes the idle
|
|
47
|
+
# session. :await - the stop hook waits after `coord await`.
|
|
48
|
+
# :poll - the agent blocks in `coord next --wait`.
|
|
49
|
+
def wait = :poll
|
|
50
|
+
|
|
51
|
+
# One more rule line for the architect prompt, or nil.
|
|
52
|
+
def architect_rule = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Setup: maf add, maf start, bootstrap, untrack, retire, and uninstall.
|
|
56
|
+
module Setup
|
|
57
|
+
# The graphify MCP server in a config file of the flow:
|
|
58
|
+
# { file: ".maf/mcp/x.json", key: "mcpServers", entry: {...} }. nil: no file.
|
|
59
|
+
def mcp = nil
|
|
60
|
+
# The command that adds the server to a global config of the harness, or nil.
|
|
61
|
+
def mcp_command(_server, _vault) = nil
|
|
62
|
+
# The project file where an older maf wrote the MCP server, or nil.
|
|
63
|
+
def legacy_mcp_file = nil
|
|
64
|
+
|
|
65
|
+
# maf add installs the hooks. A truthy result: the user has steps left.
|
|
66
|
+
def install_hooks(_project) = nil
|
|
67
|
+
# maf start refreshes the hooks in the worktree before the session starts.
|
|
68
|
+
def runtime_hooks(_worktree) = nil
|
|
69
|
+
# Bootstrap installs the hooks of the harness only if this folder exists.
|
|
70
|
+
def bootstrap_dir = nil
|
|
71
|
+
|
|
72
|
+
# Uninstall: objects with #steps that remove the files of the flow.
|
|
73
|
+
def cleaners(_project) = []
|
|
74
|
+
# Uninstall and untrack: objects with #steps that remove what an older
|
|
75
|
+
# maf wrote into the project's own files.
|
|
76
|
+
def legacy_cleaners(_project) = []
|
|
77
|
+
# Files outside the project that uninstall keeps: other projects can use them.
|
|
78
|
+
def global_files = []
|
|
79
|
+
|
|
80
|
+
def vm_install = nil
|
|
81
|
+
def vm_credentials = []
|
|
82
|
+
def vm_login_hint = "maf vm shell, then start #{command} and log in"
|
|
83
|
+
|
|
84
|
+
# The top folder of the harness. maf retire ignores changes in it.
|
|
85
|
+
def home_dir = role_dir&.split("/")&.first
|
|
86
|
+
# Files that the flow writes in a worktree. coord keeps them out of git.
|
|
87
|
+
def runtime_files = []
|
|
88
|
+
# Folders that uninstall removes when they are empty. Children come first.
|
|
89
|
+
def empty_dirs = [role_dir, home_dir].compact.uniq
|
|
90
|
+
|
|
91
|
+
# The paths that git tracks for the flow. tracked.call(path) lists the
|
|
92
|
+
# tracked files below path. By default, the links into .maf/agents/.
|
|
93
|
+
def untrack_paths(root, tracked)
|
|
94
|
+
return [] unless role_dir
|
|
95
|
+
|
|
96
|
+
tracked.call(role_dir).select do |path|
|
|
97
|
+
File.symlink?(File.join(root, path)) && File.readlink(File.join(root, path)).include?(AGENTS_DIR)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Run: maf start (an interactive session) and the dispatcher (one run per task).
|
|
103
|
+
module Run
|
|
104
|
+
# The command of an interactive session.
|
|
105
|
+
def launch(_role, _model, _effort = nil) = raise(NotImplementedError, "#{name}: launch")
|
|
106
|
+
def launch_env = {}
|
|
107
|
+
|
|
108
|
+
def sandbox_dirs = []
|
|
109
|
+
def sandbox_state = []
|
|
110
|
+
def sandbox_prefixes = []
|
|
111
|
+
def sandbox_protected = []
|
|
112
|
+
def sandbox_project = []
|
|
113
|
+
def in_dir(dir, names) = names.map { |name| File.join(dir, name) }
|
|
114
|
+
def sandbox_placeholders = sandbox_protected.reject { |path| path.end_with?(".md", ".yaml") }
|
|
115
|
+
def sandboxed(cmd) = cmd
|
|
116
|
+
|
|
117
|
+
# The command of one dispatched run. session_id is nil for a fresh session.
|
|
118
|
+
def build_command(_config, _prompt, _session_id) = raise(NotImplementedError, "#{name}: build_command")
|
|
119
|
+
def run_env(_config) = {}
|
|
120
|
+
# The dispatcher calls it once after it parses the options.
|
|
121
|
+
def prepare(config) = config
|
|
122
|
+
# Seconds that the prompt cache of the harness stays warm, or nil for the default.
|
|
123
|
+
def cache_window = nil
|
|
124
|
+
def peak_rate?(_config) = false
|
|
125
|
+
|
|
126
|
+
# The parts of the run output. nil: the harness does not report it.
|
|
127
|
+
def stale_session?(_output) = false
|
|
128
|
+
def session_id(_output) = nil
|
|
129
|
+
def result_text(_output) = nil
|
|
130
|
+
def usage(_output) = nil
|
|
131
|
+
def context(_output) = nil
|
|
132
|
+
def model(_output) = nil
|
|
133
|
+
|
|
134
|
+
# A file of the flow in .maf/ of the main checkout. The dispatcher finds
|
|
135
|
+
# .maf/ next to its coordination folder, which COORD_DIR can move.
|
|
136
|
+
def flow_file(*parts) = File.join(Project.root, ".maf", *parts)
|
|
137
|
+
def run_file(config, *parts) = File.join(File.dirname(config.coord_dir), *parts)
|
|
138
|
+
def flag_if(flag, path) = File.exist?(path) ? [flag, path] : []
|
|
139
|
+
|
|
140
|
+
def present(value) = value.to_s.empty? ? nil : value
|
|
141
|
+
def model_flag(model, flag = "--model") = model ? [flag, model] : []
|
|
142
|
+
def effort_flag(effort, flag = "--effort") = effort ? [flag, effort] : []
|
|
143
|
+
def resume(session_id, *flag) = session_id ? [*flag, session_id] : []
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|