maf 0.1.0 → 0.2.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 +131 -0
- data/README.md +11 -10
- data/assets/analyst +29 -51
- data/assets/coord +23 -20
- data/assets/dashboard +1 -5
- data/assets/dispatcher +69 -286
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/cli.rb +3 -0
- 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/generator.rb +1 -1
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +2 -2
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +6 -41
- data/lib/maf/flow/options.rb +4 -2
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +7 -11
- data/lib/maf/flow/report.rb +2 -4
- data/lib/maf/flow/role_files.rb +4 -19
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +4 -20
- data/lib/maf/menu.rb +1 -1
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/dispatch.rb +1 -1
- data/lib/maf/setup_agent/launcher.rb +15 -66
- 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 +1 -3
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +140 -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 +134 -0
- data/lib/maf/shared/harnesses/codex.rb +173 -0
- data/lib/maf/shared/harnesses/cursor.rb +64 -0
- data/lib/maf/shared/harnesses/hermes.rb +112 -0
- data/lib/maf/shared/harnesses/opencode.rb +169 -0
- data/lib/maf/shared/processes.rb +20 -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_command.rb +3 -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 +6 -26
- 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 +4 -7
- data/lib/maf/workers.rb +27 -0
- metadata +30 -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
|
@@ -18,12 +18,12 @@ module Maf
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
-
# Bootstrap
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# Bootstrap installs the hooks of a harness only if its folder exists
|
|
22
|
+
# (Harness#bootstrap_dir). Flow writes the role files after bootstrap,
|
|
23
|
+
# so create these dirs first.
|
|
24
24
|
def make_harness_dirs
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
dirs = @options.agents.map { |a| Harness.fetch(a[:harness]).bootstrap_dir }.compact.uniq
|
|
26
|
+
dirs.each { |dir| FileUtils.mkdir_p(File.join(@options.project, dir)) }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def command
|
data/lib/maf/flow/codex_hooks.rb
CHANGED
|
@@ -10,6 +10,14 @@ module Maf
|
|
|
10
10
|
FILE = ".codex/hooks.json"
|
|
11
11
|
TIMEOUT = { "timeout" => 3600 }.freeze
|
|
12
12
|
|
|
13
|
+
# A hooks file with only the flow hooks belongs to the flow.
|
|
14
|
+
def self.flow_only?(path)
|
|
15
|
+
hooks = JSON.parse(File.read(path)).fetch("hooks", {}).values.flatten.flat_map { |e| e.fetch("hooks", []) }
|
|
16
|
+
(hooks.map { |hook| hook["command"] } - COMMANDS).empty?
|
|
17
|
+
rescue JSON::ParserError, SystemCallError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
13
21
|
def initialize(project)
|
|
14
22
|
@project = project
|
|
15
23
|
@path = File.join(project, FILE)
|
|
@@ -25,8 +33,11 @@ module Maf
|
|
|
25
33
|
private
|
|
26
34
|
|
|
27
35
|
def save(data)
|
|
36
|
+
text = JSON.pretty_generate(data)
|
|
37
|
+
return if File.exist?(@path) && File.read(@path) == text
|
|
38
|
+
|
|
28
39
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
29
|
-
File.write(@path,
|
|
40
|
+
File.write(@path, text)
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
# A project that tracks its own Codex hooks keeps the file in git.
|
data/lib/maf/flow/generator.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Maf
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def finish(results)
|
|
40
|
-
pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install
|
|
40
|
+
pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install.any?
|
|
41
41
|
manifest.write
|
|
42
42
|
Report.new(@options.project, @roles, pending).print(results)
|
|
43
43
|
end
|
|
@@ -18,8 +18,8 @@ module Maf
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
-
def harnesses = @options.agents.map { |a| a[:harness] }.uniq.select { |h|
|
|
22
|
-
def dir(harness) =
|
|
21
|
+
def harnesses = @options.agents.map { |a| a[:harness] }.uniq.select { |h| Harness.fetch(h).role_dir }
|
|
22
|
+
def dir(harness) = Harness.fetch(harness).role_dir
|
|
23
23
|
|
|
24
24
|
def link_role_files(harness)
|
|
25
25
|
return warn_not_linked(harness) if File.symlink?(File.join(@options.project, dir(harness)))
|
data/lib/maf/flow/hook_files.rb
CHANGED
|
@@ -4,6 +4,12 @@ module Maf
|
|
|
4
4
|
module Flow
|
|
5
5
|
# HookFiles copies a hook script from the assets and makes it executable.
|
|
6
6
|
module HookFiles
|
|
7
|
+
# Installs a hook script of the assets in .maf/coordination/harness-hooks/ of the project.
|
|
8
|
+
def self.install(project, name)
|
|
9
|
+
dest = File.join(project, ".maf", "coordination", "harness-hooks", name)
|
|
10
|
+
copy(File.join(ASSETS, "harness-hooks", name), dest)
|
|
11
|
+
end
|
|
12
|
+
|
|
7
13
|
def self.copy(src, dest)
|
|
8
14
|
FileUtils.mkdir_p(File.dirname(dest))
|
|
9
15
|
return if File.exist?(dest) && File.read(dest) == File.read(src)
|
|
@@ -3,31 +3,17 @@
|
|
|
3
3
|
module Maf
|
|
4
4
|
module Flow
|
|
5
5
|
# HookInstaller installs the harness hooks that pick up tasks when a
|
|
6
|
-
# session ends.
|
|
6
|
+
# session ends. Each harness installs its own (Harness#install_hooks).
|
|
7
|
+
# Some harnesses get theirs from bootstrap instead (Harness#bootstrap_dir).
|
|
7
8
|
class HookInstaller
|
|
8
9
|
def initialize(agents, project)
|
|
9
|
-
|
|
10
|
+
names = agents.map { |a| a[:harness] }
|
|
11
|
+
@harnesses = Harness.all.select { |harness| names.include?(harness.name) }
|
|
10
12
|
@project = project
|
|
11
13
|
end
|
|
12
14
|
|
|
13
|
-
# Returns
|
|
14
|
-
def install
|
|
15
|
-
install_codex if @harnesses.include?("codex")
|
|
16
|
-
@harnesses.include?("hermes") && HermesHookSetup.new.install
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
def install_codex
|
|
22
|
-
%w[next-task.rb session-guard.rb].each { |name| copy_hook(name) }
|
|
23
|
-
CodexHooks.new(@project).install
|
|
24
|
-
LegacyCodexHook.new.remove
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def copy_hook(name)
|
|
28
|
-
HookFiles.copy(File.join(ASSETS, "harness-hooks", name),
|
|
29
|
-
File.join(@project, ".maf", "coordination", "harness-hooks", name))
|
|
30
|
-
end
|
|
15
|
+
# Returns the result of each harness. A truthy result: the hook needs steps from the user.
|
|
16
|
+
def install = @harnesses.map { |harness| harness.install_hooks(@project) }
|
|
31
17
|
end
|
|
32
18
|
end
|
|
33
19
|
end
|
data/lib/maf/flow/manifest.rb
CHANGED
|
@@ -24,9 +24,9 @@ module Maf
|
|
|
24
24
|
|
|
25
25
|
def data
|
|
26
26
|
data = kept_keys.merge("generated_at" => Time.now.utc.iso8601, "agents" => @options.agents.map { |a| entry(a) })
|
|
27
|
-
# Only record hermes_dir when
|
|
27
|
+
# Only record hermes_dir when --hermes-dir sets it: the default is
|
|
28
28
|
# an absolute home path that would leak into a committed manifest.
|
|
29
|
-
data["hermes_dir"] = @options.hermes_dir
|
|
29
|
+
data["hermes_dir"] = @options.hermes_dir if @options.hermes_dir
|
|
30
30
|
data
|
|
31
31
|
end
|
|
32
32
|
|
data/lib/maf/flow/mcp_config.rb
CHANGED
|
@@ -3,36 +3,22 @@
|
|
|
3
3
|
module Maf
|
|
4
4
|
module Flow
|
|
5
5
|
# McpConfig writes the graphify MCP server into a config file of the flow,
|
|
6
|
-
# in .maf/mcp/. The project's
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# in .maf/mcp/. The project's own config files stay as they are. maf start
|
|
7
|
+
# and the dispatcher pass the file to the harness (Harness#launch and
|
|
8
|
+
# Harness#build_command). Harness#mcp names the file and the entry.
|
|
9
9
|
# The server runs `vault mcp`. A foreign entry with the same name stays.
|
|
10
|
-
#
|
|
11
|
-
# edit that file. It prints the command
|
|
10
|
+
# A harness with a global MCP config gives a command instead
|
|
11
|
+
# (Harness#mcp_command). maf does not edit that file. It prints the command.
|
|
12
12
|
class McpConfig
|
|
13
13
|
NAME = "graphify"
|
|
14
|
-
ARGS = %w[ruby .maf/bin/vault mcp].freeze
|
|
15
|
-
SCHEMA = "https://opencode.ai/config.json"
|
|
16
|
-
TARGETS = {
|
|
17
|
-
"claude" => { file: ".maf/mcp/claude.json", key: "mcpServers",
|
|
18
|
-
entry: { "command" => ARGS.first, "args" => ARGS.drop(1) } },
|
|
19
|
-
"opencode" => { file: ".maf/mcp/opencode.json", key: "mcp",
|
|
20
|
-
entry: { "type" => "local", "command" => ARGS, "enabled" => true } }
|
|
21
|
-
}.freeze
|
|
22
|
-
|
|
23
|
-
def self.targets = TARGETS
|
|
24
|
-
|
|
25
|
-
GLOBAL_COMMANDS = { "codex" => "codex mcp add %<name>s -- ruby %<vault>s mcp",
|
|
26
|
-
"hermes" => "hermes mcp add %<name>s --command ruby --args %<vault>s mcp" }.freeze
|
|
27
14
|
|
|
28
15
|
# The shell command for a harness with a global MCP config, or nil.
|
|
29
16
|
def self.global_command(harness, project)
|
|
30
|
-
|
|
31
|
-
GLOBAL_COMMANDS[harness]&.then { |text| format(text, name: "#{NAME}-#{File.basename(project)}", vault: vault) }
|
|
17
|
+
harness.mcp_command("#{NAME}-#{File.basename(project)}", File.join(project, ".maf", "bin", "vault"))
|
|
32
18
|
end
|
|
33
19
|
|
|
34
20
|
def initialize(project, harness)
|
|
35
|
-
@target =
|
|
21
|
+
@target = harness.mcp
|
|
36
22
|
@path = File.join(project, @target[:file])
|
|
37
23
|
end
|
|
38
24
|
|
|
@@ -63,7 +49,7 @@ module Maf
|
|
|
63
49
|
end
|
|
64
50
|
|
|
65
51
|
def save(data)
|
|
66
|
-
data = { "$schema" =>
|
|
52
|
+
data = { "$schema" => @target[:schema] }.merge(data) if @target[:schema] && !data.key?("$schema")
|
|
67
53
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
68
54
|
File.write(@path, "#{JSON.pretty_generate(data)}\n")
|
|
69
55
|
end
|
|
@@ -13,13 +13,13 @@ module Maf
|
|
|
13
13
|
def install
|
|
14
14
|
return if @options.check? || disabled?
|
|
15
15
|
|
|
16
|
-
@options.agents.map { |a| a[:harness] }.uniq.each { |
|
|
16
|
+
@options.agents.map { |a| a[:harness] }.uniq.each { |name| install_for(Harness.fetch(name)) }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def install_for(harness)
|
|
22
|
-
if
|
|
22
|
+
if harness.mcp
|
|
23
23
|
report(harness, McpConfig.new(@project, harness).write)
|
|
24
24
|
elsif (command = McpConfig.global_command(harness, @project))
|
|
25
25
|
puts "mcp: the #{harness} harness keeps MCP servers in a global file. Add the server yourself:\n #{command}"
|
|
@@ -27,7 +27,7 @@ module Maf
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def report(harness, status)
|
|
30
|
-
file =
|
|
30
|
+
file = harness.mcp.fetch(:file)
|
|
31
31
|
return puts("mcp: wrote the graphify server to #{file}") if status == :create
|
|
32
32
|
return unless status == :refuse
|
|
33
33
|
|
data/lib/maf/flow/models.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "open3"
|
|
4
|
-
|
|
5
3
|
module Maf
|
|
6
4
|
module Flow
|
|
7
5
|
# Models finds the installed harnesses and the models that each one offers.
|
|
@@ -9,53 +7,20 @@ module Maf
|
|
|
9
7
|
# harness does not know: a typo makes every dispatched run fail.
|
|
10
8
|
# A list is nil when maf cannot read it. Then every name counts as known.
|
|
11
9
|
module Models
|
|
12
|
-
# Claude Code has no command that lists models. The aliases follow the
|
|
13
|
-
# newest model of each family. A full ID is claude-<family>-<version>.
|
|
14
|
-
CLAUDE = %w[claude-opus-5-5 claude-sonnet-5-5 claude-haiku-5-5 claude-fable-5-1 opus sonnet haiku fable].freeze
|
|
15
|
-
CLAUDE_NAME = /\A(?:(?:opus|sonnet|haiku|fable)(?:\[1m\])?|opusplan|default|best|
|
|
16
|
-
(?:[\w.-]+\.)?(?:anthropic\.)?claude-(?:opus|sonnet|haiku|fable)-\d[\w.:@\[\]-]*)\z/x
|
|
17
|
-
CODEX_CACHE = File.join(Dir.home, ".codex", "models_cache.json")
|
|
18
|
-
|
|
19
10
|
# Without any harness on PATH, maf shows all of them: the user may install one later.
|
|
20
11
|
def self.installed
|
|
21
|
-
found =
|
|
22
|
-
found.empty? ?
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.on_path?(command)
|
|
26
|
-
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
|
|
12
|
+
found = Harness.all.select(&:installed?).map(&:name)
|
|
13
|
+
found.empty? ? Harness.names : found
|
|
27
14
|
end
|
|
28
15
|
|
|
16
|
+
# A harness can read its list from a CLI, so each list is read once.
|
|
29
17
|
def self.for(harness)
|
|
30
18
|
@lists ||= {}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@lists[harness] = { "claude" => -> { CLAUDE }, "codex" => -> { codex }, "opencode" => -> { opencode } }
|
|
34
|
-
.fetch(harness, -> {}).call
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def self.known?(harness, model)
|
|
38
|
-
return model.match?(CLAUDE_NAME) if harness == "claude"
|
|
39
|
-
|
|
40
|
-
self.for(harness).nil? || self.for(harness).include?(model)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Codex keeps the model list of the account in a cache file. Hidden models are internal.
|
|
44
|
-
def self.codex
|
|
45
|
-
models = JSON.parse(File.read(CODEX_CACHE)).fetch("models")
|
|
46
|
-
models.select { |m| m["visibility"] == "list" }.map { |m| m["slug"] }
|
|
47
|
-
rescue SystemCallError, JSON::ParserError, KeyError, NoMethodError
|
|
48
|
-
nil
|
|
19
|
+
@lists.key?(harness) ? @lists[harness] : @lists[harness] = Harness[harness]&.models
|
|
49
20
|
end
|
|
50
21
|
|
|
51
|
-
def self.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
out, status = Open3.capture2("opencode", "models", err: File::NULL)
|
|
55
|
-
status.success? && !out.strip.empty? ? out.split : nil
|
|
56
|
-
rescue SystemCallError
|
|
57
|
-
nil
|
|
58
|
-
end
|
|
22
|
+
def self.known?(harness, model) = Harness[harness]&.known_model?(model, self.for(harness)) != false
|
|
23
|
+
def self.default(harness) = Harness[harness]&.default_model
|
|
59
24
|
end
|
|
60
25
|
end
|
|
61
26
|
end
|
data/lib/maf/flow/options.rb
CHANGED
|
@@ -23,7 +23,6 @@ module Maf
|
|
|
23
23
|
|
|
24
24
|
def initialize(argv)
|
|
25
25
|
@argv, @agents, @removed, @models = argv, [], [], {}
|
|
26
|
-
@hermes_dir = DEFAULT_HERMES_DIR
|
|
27
26
|
@check = @force = @list_roles = false
|
|
28
27
|
@bootstrap = true
|
|
29
28
|
end
|
|
@@ -32,6 +31,9 @@ module Maf
|
|
|
32
31
|
def force? = @force
|
|
33
32
|
def bootstrap? = @bootstrap
|
|
34
33
|
|
|
34
|
+
# The settings that a harness reads for its role file folder.
|
|
35
|
+
def settings = { "hermes_dir" => @hermes_dir }.compact
|
|
36
|
+
|
|
35
37
|
def parse
|
|
36
38
|
parser.parse!(@argv)
|
|
37
39
|
self
|
|
@@ -43,7 +45,7 @@ module Maf
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def model_for(agent)
|
|
46
|
-
agent[:model] || @models[agent[:role]] || agent[:saved_model] ||
|
|
48
|
+
agent[:model] || @models[agent[:role]] || agent[:saved_model] || Harness[agent[:harness]]&.default_model
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
private
|
|
@@ -26,26 +26,19 @@ module Maf
|
|
|
26
26
|
"#{intro(data)}\n\n#{duties_block(data)}\n\n#{loop_text}"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
# woken, so it blocks in `coord next --wait`.
|
|
34
|
-
# The dispatcher adds the report block rule to each dispatched prompt.
|
|
35
|
-
def no_task_line(harness)
|
|
36
|
-
return NO_TASK_AWAIT if harness == "codex"
|
|
37
|
-
|
|
38
|
-
WAKE_HARNESSES.include?(harness) ? NO_TASK_STOP : NO_TASK_WAIT
|
|
39
|
-
end
|
|
29
|
+
# A harness that wakes an idle session lets the agent stop instead of
|
|
30
|
+
# waiting: each return of a wait costs one model call over the whole
|
|
31
|
+
# context. The dispatcher adds the report block rule to each dispatched prompt.
|
|
32
|
+
def no_task_line(harness) = NO_TASK.fetch(harness.wait)
|
|
40
33
|
|
|
41
34
|
def architect_prompt(harness, data)
|
|
42
35
|
loop_text = project_manager? ? ARCHITECT_LOOP_PM : ARCHITECT_LOOP_DIRECT
|
|
43
36
|
"#{intro(data)} You do not implement code yourself.\n\n#{duties_block(data)}\n\n" \
|
|
44
|
-
"#{format(loop_text, roles: dispatch_roles_text)}#{
|
|
37
|
+
"#{format(loop_text, roles: dispatch_roles_text)}#{harness_rule(harness)}#{workflow_block}"
|
|
45
38
|
end
|
|
46
39
|
|
|
47
40
|
# The loop text ends with a newline, so the rule lands as the last rule line.
|
|
48
|
-
def
|
|
41
|
+
def harness_rule(harness) = harness.architect_rule ? "#{harness.architect_rule}\n" : ""
|
|
49
42
|
|
|
50
43
|
# The workflow goes into the orchestrator prompt only. Workers stay workflow-blind.
|
|
51
44
|
def workflow_block = @workflow ? "\n\n#{@workflow}" : ""
|
|
@@ -56,7 +49,7 @@ module Maf
|
|
|
56
49
|
|
|
57
50
|
def project_manager_prompt(harness, data)
|
|
58
51
|
"#{intro(data)} You do not plan tasks or implement code yourself.\n\n" \
|
|
59
|
-
"#{duties_block(data)}\n\n#{format(PM_LOOP, wait_instruction: PM_WAIT.fetch(harness
|
|
52
|
+
"#{duties_block(data)}\n\n#{format(PM_LOOP, wait_instruction: PM_WAIT.fetch(harness.wait))}"
|
|
60
53
|
end
|
|
61
54
|
|
|
62
55
|
def intro(data)
|
data/lib/maf/flow/prompt_text.rb
CHANGED
|
@@ -21,8 +21,7 @@ module Maf
|
|
|
21
21
|
- Do not use subagents to verify your work.
|
|
22
22
|
TEXT
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
WAKE_HARNESSES = %w[claude opencode].freeze
|
|
24
|
+
# The no-task rule of a worker, by the wait of the harness (Harness::RoleFiles#wait).
|
|
26
25
|
NO_TASK_STOP = "- If no task and no message is available, stop. The board watcher wakes you when work arrives.\n" \
|
|
27
26
|
" Do not wait in a loop: each return of a wait costs one model call."
|
|
28
27
|
NO_TASK_WAIT = <<~TEXT.strip
|
|
@@ -37,15 +36,16 @@ module Maf
|
|
|
37
36
|
The stop hook waits for work and wakes you. Do not wait in a loop.
|
|
38
37
|
TEXT
|
|
39
38
|
|
|
39
|
+
NO_TASK = { watch: NO_TASK_STOP, await: NO_TASK_AWAIT, poll: NO_TASK_WAIT }.freeze
|
|
40
|
+
|
|
40
41
|
# The project manager waits for reports. Each check in a loop sends the
|
|
41
42
|
# whole context again, so the wait must not call the model.
|
|
42
43
|
PM_WAIT = {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
watch: "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
|
|
45
|
+
await: "To wait for reports, run `coord await`. Tell the user that you wait. Then end your turn.\n" \
|
|
46
|
+
" The stop hook wakes you when a message arrives.",
|
|
47
|
+
poll: "Wait for reports with `coord inbox project-manager --wait --timeout 540`."
|
|
47
48
|
}.freeze
|
|
48
|
-
PM_WAIT_DEFAULT = "Wait for reports with `coord inbox project-manager --wait --timeout 540`."
|
|
49
49
|
|
|
50
50
|
# Every role except the project manager reads the code graph before work.
|
|
51
51
|
# A query output stays in the context for each later call, so the budget is small.
|
|
@@ -56,10 +56,6 @@ module Maf
|
|
|
56
56
|
Put one line in the report of that task or plan: "Graph: fresh", "Graph: stale", or "Graph: missing".
|
|
57
57
|
TEXT
|
|
58
58
|
|
|
59
|
-
# Only Claude Code has ScheduleWakeup. Other harnesses never see this rule.
|
|
60
|
-
CLAUDE_ARCHITECT_RULE = "- Never call ScheduleWakeup with `stop:false` and no `prompt`. " \
|
|
61
|
-
"The call fails. Poll worker status through the task tool instead."
|
|
62
|
-
|
|
63
59
|
# The coordination contract that every role shares. It lives in the role
|
|
64
60
|
# prompt, not in the project's AGENTS.md: only a session that maf starts
|
|
65
61
|
# needs it, and the project keeps no trace of the tool.
|
data/lib/maf/flow/report.rb
CHANGED
|
@@ -74,16 +74,14 @@ module Maf
|
|
|
74
74
|
def pm?(result) = result[:agent][:role] == "project-manager"
|
|
75
75
|
|
|
76
76
|
def print_unembeddable(results)
|
|
77
|
-
list = results.select { |r| r[:model] &&
|
|
77
|
+
list = results.select { |r| r[:model] && model_command(r) }
|
|
78
78
|
return if list.empty?
|
|
79
79
|
|
|
80
80
|
puts "", "These harnesses cannot embed a model in the role file. Set it in the harness:",
|
|
81
81
|
list.map { |r| " #{r[:agent][:harness]}:#{r[:agent][:role]} -> #{model_command(r)}" }
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
def model_command(result)
|
|
85
|
-
result[:agent][:harness] == "codex" ? "codex -m #{result[:model]}" : "hermes model"
|
|
86
|
-
end
|
|
84
|
+
def model_command(result) = Harness.fetch(result[:agent][:harness]).model_hint(result[:model])
|
|
87
85
|
end
|
|
88
86
|
end
|
|
89
87
|
end
|
data/lib/maf/flow/role_files.rb
CHANGED
|
@@ -19,30 +19,15 @@ module Maf
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def generate_agent(agent)
|
|
22
|
-
|
|
22
|
+
harness = Harness.fetch(agent[:harness])
|
|
23
23
|
model = @options.model_for(agent)
|
|
24
|
-
dest =
|
|
25
|
-
content = render(agent[:
|
|
24
|
+
dest = harness.role_destination(@options.project, agent[:role], @options.settings)
|
|
25
|
+
content = render(harness, agent[:role], @roles.fetch(agent[:role]), model)
|
|
26
26
|
{ agent: agent, dest: dest, status: write(dest, content), model: model }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# Hermes skills live in a global ~/.hermes/skills/ directory, not the
|
|
30
|
-
# project. Namespace by project so two projects using the same role
|
|
31
|
-
# don't overwrite each other's skill.
|
|
32
|
-
def destination(harness, role)
|
|
33
|
-
return hermes_path(role) if harness == "hermes"
|
|
34
|
-
|
|
35
|
-
File.join(@options.project, Flow.role_path(harness, role)) if %w[opencode claude codex].include?(harness)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def hermes_path(role)
|
|
39
|
-
File.join(@options.hermes_dir, "#{File.basename(@options.project)}-#{role}", "SKILL.md")
|
|
40
|
-
end
|
|
41
|
-
|
|
42
29
|
def render(harness, role, data, model)
|
|
43
|
-
|
|
44
|
-
values = { prompt: @prompts.build(harness, role, data), model: model }
|
|
45
|
-
ERB.new(template, trim_mode: "-").result_with_hash(**template_values(role, data), **values)
|
|
30
|
+
harness.render_role(**template_values(role, data), prompt: @prompts.build(harness, role, data), model: model)
|
|
46
31
|
end
|
|
47
32
|
|
|
48
33
|
def template_values(role, data)
|
data/lib/maf/flow/validator.rb
CHANGED
|
@@ -28,11 +28,11 @@ module Maf
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
30
|
def check(agent)
|
|
31
|
-
abort "flow: unknown harness '#{agent[:harness]}' (use #{
|
|
31
|
+
abort "flow: unknown harness '#{agent[:harness]}' (use #{Harness.names.join(", ")})" unless known?(agent)
|
|
32
32
|
abort "flow: unknown role '#{agent[:role]}'" unless @roles.key?(agent[:role])
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def known?(agent) =
|
|
35
|
+
def known?(agent) = Harness.known?(agent[:harness])
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
end
|
data/lib/maf/flow.rb
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# [--model ROLE=MODEL] [--hermes-dir DIR] [--check] [--force] [--no-bootstrap]
|
|
8
8
|
# [--list-roles]
|
|
9
9
|
#
|
|
10
|
-
# HARNESS is
|
|
10
|
+
# HARNESS is a harness of the registry: one file in lib/maf/shared/harnesses/.
|
|
11
11
|
# A re-run keeps the agents in .maf/config.json. --agent adds an agent.
|
|
12
12
|
# --remove drops an agent. Without --agent, a re-run regenerates the current agents.
|
|
13
13
|
# Idempotent: identical files are skipped, changed files are updated, and
|
|
@@ -20,6 +20,7 @@ require "optparse"
|
|
|
20
20
|
require "rbconfig"
|
|
21
21
|
require "time"
|
|
22
22
|
require_relative "shared/roles"
|
|
23
|
+
require_relative "shared/harness"
|
|
23
24
|
|
|
24
25
|
abort "flow: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
|
|
25
26
|
|
|
@@ -28,32 +29,15 @@ module Maf
|
|
|
28
29
|
ROOT = File.expand_path("../..", __dir__)
|
|
29
30
|
TEMPLATES = File.join(ROOT, "templates")
|
|
30
31
|
ASSETS = File.join(ROOT, "assets")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# Model a harness gets when neither --agent nor --model names one.
|
|
35
|
-
# Other harnesses have no safe default, so their CLI picks the model.
|
|
36
|
-
DEFAULT_MODELS = { "claude" => "claude-opus-5-5" }.freeze
|
|
32
|
+
# The registry of the harnesses. Each harness is one file in lib/maf/shared/harnesses/.
|
|
33
|
+
Harness = Maf::Shared::Harness
|
|
37
34
|
|
|
38
35
|
# Roles that dispatch or coordinate instead of implementing. They are never
|
|
39
36
|
# advertised as dispatch targets in the architect prompt.
|
|
40
37
|
LEADS = Maf::Shared::Roles::LEADS
|
|
41
38
|
DISPATCH_EXCLUDE = LEADS
|
|
42
39
|
|
|
43
|
-
READ_ONLY_TOOLSETS = Maf::Shared::Roles::READ_ONLY_TOOLSETS
|
|
44
|
-
|
|
45
40
|
MAF_DIR = ".maf"
|
|
46
|
-
|
|
47
|
-
# The folder where a harness looks for role files. It is a symlink into
|
|
48
|
-
# .maf/agents/<harness>/, where the flow keeps the role files.
|
|
49
|
-
HARNESS_DIRS = { "opencode" => ".opencode/agents", "claude" => ".claude/agents",
|
|
50
|
-
"codex" => ".codex/prompts" }.freeze
|
|
51
|
-
|
|
52
|
-
# The role file path inside the project. Hermes keeps role files outside
|
|
53
|
-
# the project, so it has no path here.
|
|
54
|
-
def self.role_path(harness, role)
|
|
55
|
-
HARNESS_DIRS.key?(harness) ? File.join(MAF_DIR, "agents", harness, "#{role}.md") : nil
|
|
56
|
-
end
|
|
57
41
|
end
|
|
58
42
|
end
|
|
59
43
|
|
data/lib/maf/menu.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Maf
|
|
|
73
73
|
@prompt.confirm?("#{harness} does not list #{answer}. Use it anyway?") ? answer : ask_model(harness, role, models)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
def default_label(harness) = Flow::
|
|
76
|
+
def default_label(harness) = Flow::Models.default(harness) || "the harness default"
|
|
77
77
|
|
|
78
78
|
def show_models(harness)
|
|
79
79
|
models = Flow::Models.for(harness).to_a
|
|
@@ -11,7 +11,9 @@ module Maf
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def steps
|
|
14
|
-
|
|
14
|
+
Maf::Shared::Harness.all.select(&:role_dir).flat_map do |harness|
|
|
15
|
+
files(harness.role_dir).map { |file| step(harness.name, harness.role_dir, file) }
|
|
16
|
+
end
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
private
|
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
|
|
@@ -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
|
|