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
|
@@ -151,7 +151,8 @@ module ContextWatch
|
|
|
151
151
|
# maf start gives the role file in the first prompt, not as a system
|
|
152
152
|
# prompt. /clear drops it, so a cleared session gets the role file again.
|
|
153
153
|
# The hook puts the file text in the context: a Read costs one model call.
|
|
154
|
-
|
|
154
|
+
# The flow keeps the role file of each harness in .maf/agents/<harness>/.
|
|
155
|
+
def role_path(harness, role) = File.join(".maf", "agents", harness, "#{role}.md")
|
|
155
156
|
|
|
156
157
|
def session_start
|
|
157
158
|
@store.save("session_id" => @input["session_id"], "asked" => nil)
|
|
@@ -170,10 +171,10 @@ module ContextWatch
|
|
|
170
171
|
end
|
|
171
172
|
|
|
172
173
|
# The harness guess comes first. The file that exists in the worktree wins.
|
|
174
|
+
# The role file of this harness first, then the file of another harness of the role.
|
|
173
175
|
def role_file
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
paths.find { |file| File.exist?(file) } || paths.first
|
|
176
|
+
own = role_path(harness, @env["COORD_ROLE"])
|
|
177
|
+
File.exist?(own) ? own : Dir.glob(role_path("*", @env["COORD_ROLE"])).min || own
|
|
177
178
|
end
|
|
178
179
|
|
|
179
180
|
def note_text
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
minimumLimaVersion: 2.2.0
|
|
2
|
+
|
|
3
|
+
base:
|
|
4
|
+
- template:_images/ubuntu-26.04
|
|
5
|
+
|
|
6
|
+
vmType: <%= vm_type %>
|
|
7
|
+
mountType: <%= mount_type %>
|
|
8
|
+
cpus: <%= cpus %>
|
|
9
|
+
memory: "<%= memory %>GiB"
|
|
10
|
+
disk: "<%= disk %>GiB"
|
|
11
|
+
|
|
12
|
+
mounts:
|
|
13
|
+
# JSON quoting is valid YAML, so a quote in the path stays in the path.
|
|
14
|
+
- location: <%= project.to_json %>
|
|
15
|
+
writable: true
|
|
16
|
+
|
|
17
|
+
containerd:
|
|
18
|
+
system: false
|
|
19
|
+
user: false
|
|
20
|
+
|
|
21
|
+
provision:
|
|
22
|
+
- mode: system
|
|
23
|
+
script: |
|
|
24
|
+
#!/bin/bash
|
|
25
|
+
set -eux -o pipefail
|
|
26
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
27
|
+
command -v maf >/dev/null 2>&1 && exit 0
|
|
28
|
+
apt-get update
|
|
29
|
+
apt-get install -y ruby-full git tmux curl bubblewrap nodejs npm build-essential cmake cargo rustc uuid-dev libsqlite3-dev
|
|
30
|
+
cd /tmp
|
|
31
|
+
curl -fsSL -o task.tar.gz <%= taskwarrior_url %>
|
|
32
|
+
echo "<%= taskwarrior_sha256 %> task.tar.gz" | sha256sum -c -
|
|
33
|
+
tar xzf task.tar.gz && cd task-<%= taskwarrior %>
|
|
34
|
+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$(nproc)" && cmake --install build
|
|
35
|
+
cd / && rm -rf /tmp/task.tar.gz /tmp/task-<%= taskwarrior %> /root/.cargo/registry && apt-get clean
|
|
36
|
+
gem install <%= gem_args %>
|
|
37
|
+
<% harness_installs.each do |name, (cli, install)| -%>
|
|
38
|
+
- mode: user
|
|
39
|
+
script: |
|
|
40
|
+
#!/bin/bash
|
|
41
|
+
set -eux -o pipefail
|
|
42
|
+
export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH"
|
|
43
|
+
command -v <%= cli %> >/dev/null 2>&1 && exit 0
|
|
44
|
+
<%= install %>
|
|
45
|
+
<% end -%>
|
|
46
|
+
- mode: user
|
|
47
|
+
script: |
|
|
48
|
+
#!/bin/bash
|
|
49
|
+
grep -q 'maf vm' ~/.bashrc && exit 0
|
|
50
|
+
echo 'export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH" # maf vm' >> ~/.bashrc
|
|
51
|
+
|
|
52
|
+
probes:
|
|
53
|
+
- description: maf is installed
|
|
54
|
+
script: |
|
|
55
|
+
#!/bin/bash
|
|
56
|
+
set -eux -o pipefail
|
|
57
|
+
if ! timeout 1800s bash -c "until command -v maf >/dev/null 2>&1; do sleep 5; done"; then
|
|
58
|
+
echo >&2 "maf is not installed yet"
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
hint: See /var/log/cloud-init-output.log in the VM.
|
data/install.md
CHANGED
|
@@ -198,7 +198,12 @@ list with the existing entries plus the new entry.
|
|
|
198
198
|
The second command approves the hook one time. Hermes stores the consent for this
|
|
199
199
|
version of the script. A new script version needs a new approval.
|
|
200
200
|
|
|
201
|
-
Confirm that every check from `hermes hooks doctor` passes.
|
|
201
|
+
Confirm that every check from `hermes hooks doctor` passes.
|
|
202
|
+
|
|
203
|
+
Hermes gives the commands of an agent only the variables in
|
|
204
|
+
`terminal.client_env_passthrough`. If `maf add` prints a `hermes config set
|
|
205
|
+
terminal.client_env_passthrough` command, run it. It keeps the names that the
|
|
206
|
+
list has now. Then continue.
|
|
202
207
|
|
|
203
208
|
## Step 5c - Set the Gemini key for the doc-graph refresh
|
|
204
209
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Maf
|
|
4
4
|
module Bootstrap
|
|
5
|
-
# ClaudeSettings plans and applies the Claude Code hooks
|
|
6
|
-
# file of the flow, .maf/claude/settings.json. `maf start`
|
|
7
|
-
# with --settings. Claude Code runs these hooks next to the
|
|
5
|
+
# ClaudeSettings plans and applies the Claude Code hooks and environment in
|
|
6
|
+
# the settings file of the flow, .maf/claude/settings.json. `maf start`
|
|
7
|
+
# passes the file with --settings. Claude Code runs these hooks next to the
|
|
8
|
+
# project's own. The --settings env wins over the env of the user settings.
|
|
8
9
|
class ClaudeSettings
|
|
9
10
|
def initialize(project)
|
|
10
11
|
@project = project
|
|
@@ -12,23 +13,30 @@ module Maf
|
|
|
12
13
|
|
|
13
14
|
def plan
|
|
14
15
|
file = @project.path(CLAUDE_SETTINGS)
|
|
15
|
-
[@project.action(status(file), file, "#{file} (next-task + board-watch hooks)", :claude_stop_hook)]
|
|
16
|
+
[@project.action(status(file), file, "#{file} (next-task + board-watch hooks, env)", :claude_stop_hook)]
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def configure(file)
|
|
19
20
|
settings = read(file)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
write(file, settings)
|
|
23
|
-
|
|
21
|
+
return false if current?(settings)
|
|
22
|
+
|
|
23
|
+
write(file, update(settings))
|
|
24
|
+
true
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
private
|
|
27
28
|
|
|
28
29
|
def status(file)
|
|
29
|
-
File.exist?(file) &&
|
|
30
|
+
File.exist?(file) && current?(read(file)) ? :skip : :configure_claude_hook
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update(settings)
|
|
34
|
+
missing_hooks(settings).each { |event, hook| add_hook(settings, event, hook) }
|
|
35
|
+
settings.merge("env" => (settings["env"] || {}).merge(CLAUDE_ENV))
|
|
30
36
|
end
|
|
31
37
|
|
|
38
|
+
def current?(settings) = missing_hooks(settings).empty? && (settings["env"] || {}) >= CLAUDE_ENV
|
|
39
|
+
|
|
32
40
|
def read(file)
|
|
33
41
|
File.exist?(file) ? (JSON.parse(File.read(file)) rescue {}) : {}
|
|
34
42
|
end
|
|
@@ -20,7 +20,7 @@ module Maf
|
|
|
20
20
|
|
|
21
21
|
# The shared library that the scripts load. Each file is planned as a script.
|
|
22
22
|
def shared
|
|
23
|
-
Dir.
|
|
23
|
+
Dir.glob("**/*.rb", base: File.join(@project.assets, SHARED_SOURCE)).sort.map do |name|
|
|
24
24
|
script(File.join(SHARED_SOURCE, name), SHARED_SIGNATURE, @project.path(MAF_DIR, SHARED_DIR, name))
|
|
25
25
|
end
|
|
26
26
|
end
|
data/lib/maf/bootstrap.rb
CHANGED
|
@@ -64,6 +64,10 @@ module Maf
|
|
|
64
64
|
end
|
|
65
65
|
].freeze
|
|
66
66
|
|
|
67
|
+
# Claude Code environment of a flow session. Agent teams stay off: with
|
|
68
|
+
# them on, a subagent that Claude names starts as a teammate.
|
|
69
|
+
CLAUDE_ENV = { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" => "0" }.freeze
|
|
70
|
+
|
|
67
71
|
# The knowledge graph and the Obsidian vault, at the project root where graphify looks by default.
|
|
68
72
|
GRAPH_DIR = "graphify-out"
|
|
69
73
|
|
data/lib/maf/cli.rb
CHANGED
|
@@ -12,7 +12,9 @@ require_relative "menu"
|
|
|
12
12
|
require_relative "team"
|
|
13
13
|
require_relative "team_command"
|
|
14
14
|
require_relative "worker_control"
|
|
15
|
+
require_relative "worker_list"
|
|
15
16
|
require_relative "untrack"
|
|
17
|
+
require_relative "vm"
|
|
16
18
|
require_relative "version"
|
|
17
19
|
|
|
18
20
|
module Maf
|
|
@@ -20,6 +22,7 @@ module Maf
|
|
|
20
22
|
|
|
21
23
|
def self.flow(*args) = Flow::Generator.new(["--project", Dir.pwd, *args]).run
|
|
22
24
|
def self.role_names = Flow::RoleCatalog.new(Dir.pwd).roles.keys
|
|
25
|
+
def self.role_effort(role) = Flow::RoleCatalog.new(Dir.pwd).roles.dig(role, "effort")
|
|
23
26
|
|
|
24
27
|
def self.agents = File.exist?(MANIFEST) ? JSON.parse(File.read(MANIFEST)).fetch("agents") : []
|
|
25
28
|
def self.agent_specs = agents.map { |a| "#{a["harness"]}:#{a["role"]}" }
|
|
@@ -37,23 +40,26 @@ module Maf
|
|
|
37
40
|
|
|
38
41
|
class CLI
|
|
39
42
|
COMMANDS = {
|
|
40
|
-
"add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] add agents and install
|
|
41
|
-
"without arguments it asks",
|
|
43
|
+
"add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] [--effort ROLE=LEVEL] add agents and install " \
|
|
44
|
+
"the flow; without arguments it asks",
|
|
42
45
|
"remove" => "HARNESS:ROLE ... remove agents",
|
|
43
46
|
"update" => " regenerate the files of the current agents",
|
|
44
47
|
"agents" => " list the current agents",
|
|
45
48
|
"roles" => " list the built-in roles and the project roles",
|
|
46
49
|
"role" => "add NAME add a stub role to .maf/roles.yml",
|
|
47
|
-
"start" => "[HARNESS ROLE[_WORKER]] [--dispatch [--detach]] start one agent in its worktree",
|
|
48
|
-
"prepare" => "HARNESS ROLE[_WORKER] [--dispatch|--interactive] [--replace W]
|
|
50
|
+
"start" => "[HARNESS ROLE[_WORKER]] [--effort LEVEL] [--dispatch [--detach]] start one agent in its worktree",
|
|
51
|
+
"prepare" => "HARNESS ROLE[_WORKER] [--effort LEVEL] [--dispatch|--interactive] [--replace W] " \
|
|
52
|
+
"prepare a worker; " \
|
|
49
53
|
"--dispatch starts it (default for the architect)",
|
|
50
54
|
"retire" => "ROLE[_WORKER] remove a worker; its tasks return to the pool",
|
|
51
|
-
"worker" => "status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N]
|
|
52
|
-
"[--cache-window S]
|
|
55
|
+
"worker" => "list | status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N] " \
|
|
56
|
+
"[--max-session-runs N] [--cache-window S] list the running workers, or control one worker; " \
|
|
57
|
+
"start and restart save the session limits of the role",
|
|
53
58
|
"team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
|
|
54
59
|
"uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
|
|
55
60
|
"migrate" => "[--check] [--yes] move an old-layout install into .maf/",
|
|
56
61
|
"untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
|
|
62
|
+
"vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
|
|
57
63
|
"menu" => " interactive mode (also: maf without a command)",
|
|
58
64
|
"guide" => " print the setup guide for an AI agent (install.md)",
|
|
59
65
|
"version" => " print the maf version (also: --version)"
|
|
@@ -93,7 +99,10 @@ module Maf
|
|
|
93
99
|
# Without arguments on a terminal, maf add asks for the harness, the roles, and the models.
|
|
94
100
|
def run_add = @args.empty? && $stdin.tty? ? Menu.new.add : Maf.flow(*AgentArgs.convert(@args, "--agent"))
|
|
95
101
|
def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
|
|
96
|
-
def run_update
|
|
102
|
+
def run_update
|
|
103
|
+
Maf.flow(*@args)
|
|
104
|
+
Untrack.warn_tracked(SetupAgent::Project.root)
|
|
105
|
+
end
|
|
97
106
|
def run_roles = Maf.flow("--list-roles")
|
|
98
107
|
|
|
99
108
|
def run_role
|
|
@@ -106,17 +115,20 @@ module Maf
|
|
|
106
115
|
def run_prepare = Prepare.new(@args).run
|
|
107
116
|
def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
|
|
108
117
|
def run_worker
|
|
118
|
+
return WorkerList.new(SetupAgent::Project.root).run if @args.first == "list"
|
|
119
|
+
|
|
109
120
|
spec = @args[1] || abort(worker_usage)
|
|
110
121
|
limits = RoleLimits.parse(@args)
|
|
111
122
|
WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
|
|
112
123
|
.run(@args[0].to_s)
|
|
113
124
|
end
|
|
114
125
|
|
|
115
|
-
def worker_usage = "usage: maf worker #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
|
|
126
|
+
def worker_usage = "usage: maf worker list | #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
|
|
116
127
|
"[--max-context N] [--max-session-runs N] [--cache-window S]"
|
|
117
128
|
def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
|
|
118
129
|
def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
119
130
|
def run_menu = Menu.new.run
|
|
131
|
+
def run_vm = Vm.new(@args, SetupAgent::Project.root).run
|
|
120
132
|
def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
|
|
121
133
|
def run_version = puts("maf #{VERSION}")
|
|
122
134
|
def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
|
|
@@ -129,7 +141,8 @@ module Maf
|
|
|
129
141
|
end
|
|
130
142
|
|
|
131
143
|
def agent_line(agent)
|
|
132
|
-
|
|
144
|
+
effort = agent["effort"] && "effort=#{agent["effort"]}"
|
|
145
|
+
"#{agent["harness"]}:#{agent["role"]}".ljust(32) + [agent["model"], effort].compact.join(" ")
|
|
133
146
|
end
|
|
134
147
|
end
|
|
135
148
|
end
|
data/lib/maf/flow/agent_links.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Maf
|
|
|
13
13
|
# Returns :create, :skip, or :refuse. :refuse means the folder holds files
|
|
14
14
|
# that the flow does not own, or a symlink to another place.
|
|
15
15
|
def link(harness)
|
|
16
|
-
path = File.join(@project,
|
|
16
|
+
path = File.join(@project, Harness.fetch(harness).role_dir)
|
|
17
17
|
return exclude(path) && :skip if linked?(path, harness)
|
|
18
18
|
return :refuse unless free?(path)
|
|
19
19
|
|
|
@@ -28,7 +28,7 @@ module Maf
|
|
|
28
28
|
# relative symlink in it instead, so the harness still finds the role.
|
|
29
29
|
# A user file with the same name stays, and its name is returned.
|
|
30
30
|
def link_files(harness)
|
|
31
|
-
dir = File.join(@project,
|
|
31
|
+
dir = File.join(@project, Harness.fetch(harness).role_dir)
|
|
32
32
|
prune(dir, harness)
|
|
33
33
|
role_files(harness).reject { |source| link_file(dir, source) }.map { |source| File.basename(source) }
|
|
34
34
|
end
|
|
@@ -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.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# EffortReport warns about an effort level that the harness does not list.
|
|
6
|
+
module EffortReport
|
|
7
|
+
def self.print(results)
|
|
8
|
+
list = results.select { |r| r[:effort] && !harness(r).known_effort?(r[:effort], r[:model]) }
|
|
9
|
+
return if list.empty?
|
|
10
|
+
|
|
11
|
+
puts "", "WARNING: the harness does not list these effort levels. Check the level for a typo:",
|
|
12
|
+
list.map { |r| " #{name(r)} -> #{r[:effort]}" },
|
|
13
|
+
" Fix one with: maf add HARNESS:ROLE --effort ROLE=LEVEL"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.harness(result) = Harness.fetch(result[:agent][:harness])
|
|
17
|
+
def self.name(result) = "#{result[:agent][:harness]}:#{result[:agent][:role]}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/maf/flow/generator.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Maf
|
|
|
26
26
|
Validator.project!(@options)
|
|
27
27
|
@catalog = RoleCatalog.new(@options.project)
|
|
28
28
|
@roles = @catalog.roles
|
|
29
|
+
@options.role_efforts = @roles.transform_values { |data| data["effort"] }
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def generate_all
|
|
@@ -37,7 +38,7 @@ module Maf
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def finish(results)
|
|
40
|
-
pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install
|
|
41
|
+
pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install.any?
|
|
41
42
|
manifest.write
|
|
42
43
|
Report.new(@options.project, @roles, pending).print(results)
|
|
43
44
|
end
|
|
@@ -51,7 +52,7 @@ module Maf
|
|
|
51
52
|
|
|
52
53
|
def print_role(key, data)
|
|
53
54
|
puts "", " #{key} (#{@catalog.source(key)})", " #{data.fetch("description")}",
|
|
54
|
-
" model hint: #{data.fetch("model_hint")}"
|
|
55
|
+
" model hint: #{data.fetch("model_hint")}", *(" effort: #{data["effort"]}" if data["effort"])
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def finish_without_agents
|
|
@@ -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)))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# HermesEnvSetup prints the step that lets Hermes give the flow variables to
|
|
6
|
+
# the commands of an agent. The flow never edits the Hermes config.
|
|
7
|
+
class HermesEnvSetup
|
|
8
|
+
KEY = "terminal.client_env_passthrough"
|
|
9
|
+
NAMES = %w[COORD_DIR COORD_ROLE COORD_WORKER COORD_DISPATCHED COORD_SLOT COORD_SESSION_PID TASKRC
|
|
10
|
+
MAF_BIN].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(config_yaml = File.join(Dir.home, ".hermes", "config.yaml"))
|
|
13
|
+
@config_yaml = config_yaml
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns true when the user has a step left.
|
|
17
|
+
def install
|
|
18
|
+
names = listed
|
|
19
|
+
return puts(" hermes env ready: #{KEY}") || false if (NAMES - names).empty?
|
|
20
|
+
|
|
21
|
+
print_step(names) || true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def command(names) = "hermes config set #{KEY} '#{JSON.generate((names + NAMES).uniq)}'"
|
|
25
|
+
|
|
26
|
+
def listed
|
|
27
|
+
list = config.dig("terminal", "client_env_passthrough")
|
|
28
|
+
list.is_a?(Array) ? list.map(&:to_s) : []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def print_step(names)
|
|
34
|
+
puts "", " Hermes env: the commands of a Hermes agent get no COORD_* variables yet.",
|
|
35
|
+
" Do this step one time. It keeps the names that the list has now.", "", " #{command(names)}", ""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def config
|
|
39
|
+
data = YAML.safe_load_file(@config_yaml)
|
|
40
|
+
data.is_a?(Hash) ? data : {}
|
|
41
|
+
rescue SystemCallError, Psych::Exception
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
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
|
|
|
@@ -34,7 +34,7 @@ module Maf
|
|
|
34
34
|
# a role without the role definitions.
|
|
35
35
|
def entry(agent)
|
|
36
36
|
{ harness: agent[:harness], role: agent[:role], model: @options.model_for(agent),
|
|
37
|
-
can_edit: @roles.fetch(agent[:role]).fetch("can_edit") }
|
|
37
|
+
can_edit: @roles.fetch(agent[:role]).fetch("can_edit") }.merge({ effort: @options.effort_for(agent) }.compact)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# Other tools own other keys (maf team: "team"; users: "base_branch").
|
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
|
|