maf 0.1.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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +411 -0
- data/assets/agents-contract.md +80 -0
- data/assets/analyst +240 -0
- data/assets/coord +2936 -0
- data/assets/dashboard +553 -0
- data/assets/dashboard.html +341 -0
- data/assets/dispatcher +1687 -0
- data/assets/doc-graph-refresh +286 -0
- data/assets/env.sh +6 -0
- data/assets/git-hooks/post-commit +7 -0
- data/assets/git-hooks/post-merge +7 -0
- data/assets/git-hooks/pre-commit +32 -0
- data/assets/harness-hooks/board-watch-opencode.js +87 -0
- data/assets/harness-hooks/board-watch.rb +286 -0
- data/assets/harness-hooks/context-watch.rb +268 -0
- data/assets/harness-hooks/next-task-hermes.sh +48 -0
- data/assets/harness-hooks/next-task.rb +97 -0
- data/assets/harness-hooks/session-guard.rb +128 -0
- data/assets/taskrc.append +11 -0
- data/assets/vault +224 -0
- data/assets/worktree-env.example.rb +26 -0
- data/exe/maf +14 -0
- data/install.md +326 -0
- data/lib/maf/bootstrap/claude_settings.rb +55 -0
- data/lib/maf/bootstrap/dependencies.rb +37 -0
- data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
- data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
- data/lib/maf/bootstrap/graph_home.rb +62 -0
- data/lib/maf/bootstrap/hook_merger.rb +53 -0
- data/lib/maf/bootstrap/installer.rb +66 -0
- data/lib/maf/bootstrap/layout_planner.rb +18 -0
- data/lib/maf/bootstrap/marked_block.rb +44 -0
- data/lib/maf/bootstrap/memory_branch.rb +77 -0
- data/lib/maf/bootstrap/options.rb +34 -0
- data/lib/maf/bootstrap/project.rb +77 -0
- data/lib/maf/bootstrap/script_planner.rb +81 -0
- data/lib/maf/bootstrap/text_planner.rb +42 -0
- data/lib/maf/bootstrap/vault_starter.rb +41 -0
- data/lib/maf/bootstrap/writer.rb +69 -0
- data/lib/maf/bootstrap.rb +162 -0
- data/lib/maf/budget.rb +59 -0
- data/lib/maf/cli.rb +135 -0
- data/lib/maf/env_exclude.rb +23 -0
- data/lib/maf/flow/agent_links.rb +79 -0
- data/lib/maf/flow/bootstrapper.rb +36 -0
- data/lib/maf/flow/codex_hooks.rb +50 -0
- data/lib/maf/flow/generator.rb +63 -0
- data/lib/maf/flow/harness_linker.rb +37 -0
- data/lib/maf/flow/hermes_hook.rb +48 -0
- data/lib/maf/flow/hermes_hook_setup.rb +69 -0
- data/lib/maf/flow/hook_files.rb +16 -0
- data/lib/maf/flow/hook_installer.rb +33 -0
- data/lib/maf/flow/legacy_codex_hook.rb +71 -0
- data/lib/maf/flow/manifest.rb +51 -0
- data/lib/maf/flow/mcp_config.rb +72 -0
- data/lib/maf/flow/mcp_installer.rb +45 -0
- data/lib/maf/flow/models.rb +61 -0
- data/lib/maf/flow/options.rb +65 -0
- data/lib/maf/flow/prompt_builder.rb +85 -0
- data/lib/maf/flow/prompt_text.rb +263 -0
- data/lib/maf/flow/report.rb +89 -0
- data/lib/maf/flow/role_catalog.rb +40 -0
- data/lib/maf/flow/role_files.rb +72 -0
- data/lib/maf/flow/role_stub.rb +38 -0
- data/lib/maf/flow/roster.rb +28 -0
- data/lib/maf/flow/validator.rb +38 -0
- data/lib/maf/flow/workflow.rb +28 -0
- data/lib/maf/flow.rb +84 -0
- data/lib/maf/local_exclude.rb +53 -0
- data/lib/maf/menu.rb +101 -0
- data/lib/maf/migrate/moves.rb +44 -0
- data/lib/maf/migrate/rewrites.rb +53 -0
- data/lib/maf/migrate/role_files.rb +35 -0
- data/lib/maf/migrate/runner.rb +66 -0
- data/lib/maf/migrate/worktrees.rb +65 -0
- data/lib/maf/migrate.rb +62 -0
- data/lib/maf/prompt.rb +40 -0
- data/lib/maf/retire.rb +116 -0
- data/lib/maf/role_limits.rb +49 -0
- data/lib/maf/setup_agent/args.rb +57 -0
- data/lib/maf/setup_agent/dispatch.rb +44 -0
- data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
- data/lib/maf/setup_agent/hermes_skill.rb +26 -0
- data/lib/maf/setup_agent/launcher.rb +85 -0
- data/lib/maf/setup_agent/manifest.rb +35 -0
- data/lib/maf/setup_agent/project.rb +9 -0
- data/lib/maf/setup_agent/role_file.rb +30 -0
- data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
- data/lib/maf/setup_agent/worktree.rb +50 -0
- data/lib/maf/setup_agent.rb +111 -0
- data/lib/maf/shared/git_exclude.rb +33 -0
- data/lib/maf/shared/git_identity.rb +41 -0
- data/lib/maf/shared/peak_rate.rb +20 -0
- data/lib/maf/shared/processes.rb +31 -0
- data/lib/maf/shared/project.rb +34 -0
- data/lib/maf/shared/roles.rb +19 -0
- data/lib/maf/team.rb +114 -0
- data/lib/maf/team_command.rb +73 -0
- data/lib/maf/uninstall/claude_settings.rb +40 -0
- data/lib/maf/uninstall/codex_hooks.rb +18 -0
- data/lib/maf/uninstall/commit_guard.rb +16 -0
- data/lib/maf/uninstall/coordination.rb +15 -0
- data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
- data/lib/maf/uninstall/git.rb +13 -0
- data/lib/maf/uninstall/local_files.rb +33 -0
- data/lib/maf/uninstall/manifest.rb +29 -0
- data/lib/maf/uninstall/marked_files.rb +37 -0
- data/lib/maf/uninstall/mcp_entries.rb +43 -0
- data/lib/maf/uninstall/notes.rb +31 -0
- data/lib/maf/uninstall/owned.rb +12 -0
- data/lib/maf/uninstall/role_files.rb +51 -0
- data/lib/maf/uninstall/runner.rb +67 -0
- data/lib/maf/uninstall/scripts.rb +35 -0
- data/lib/maf/uninstall/vault_watcher.rb +21 -0
- data/lib/maf/uninstall/worktrees.rb +30 -0
- data/lib/maf/uninstall.rb +59 -0
- data/lib/maf/untrack.rb +90 -0
- data/lib/maf/version.rb +5 -0
- data/lib/maf/worker_archive.rb +63 -0
- data/lib/maf/worker_control.rb +137 -0
- data/lib/maf/workers.rb +37 -0
- data/lib/maf.rb +5 -0
- data/templates/claude.md.erb +16 -0
- data/templates/codex.md.erb +7 -0
- data/templates/hermes.md.erb +12 -0
- data/templates/opencode.md.erb +24 -0
- data/templates/role-stub.yml.erb +15 -0
- data/templates/roles.yml +289 -0
- data/templates/workflows/panel.md +20 -0
- data/templates/workflows/plan-review.md +9 -0
- data/templates/workflows/simple.md +4 -0
- data/templates/workflows/tdd.md +8 -0
- metadata +193 -0
data/lib/maf/retire.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "setup_agent"
|
|
4
|
+
require_relative "workers"
|
|
5
|
+
require_relative "worker_archive"
|
|
6
|
+
require_relative "shared/processes"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
class Retire
|
|
10
|
+
RUNTIME_PREFIXES = %w[.maf/ .claude/ .opencode/ .codex/].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(root, spec)
|
|
13
|
+
@root = root
|
|
14
|
+
@worker = Workers.id(spec)
|
|
15
|
+
@dir = SetupAgent::Worktree.dir_for(root, @worker)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check!
|
|
19
|
+
abort "maf: no worker #{@worker}" unless Workers.at(@root).find(@worker) || Dir.exist?(@dir)
|
|
20
|
+
stop_dispatcher
|
|
21
|
+
pids = RunningProcesses.in(@dir)
|
|
22
|
+
abort "maf: #{@worker} still runs (pid #{pids.join(", ")}). Stop its session, then run this again." if pids.any?
|
|
23
|
+
abort "maf: #{@dir} has uncommitted work. Commit it on the task branch, then run this again." if dirty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
check!
|
|
28
|
+
released = release_tasks
|
|
29
|
+
remove_worker
|
|
30
|
+
Team.notify(@root, "Team change: worker #{@worker} left. #{released} task(s) returned to the pool.")
|
|
31
|
+
puts "Worker #{@worker} retired. #{released} task(s) returned to the pool."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def role = Workers.at(@root).find(@worker)&.fetch("role") || @worker.sub(/-[^-]+\z/, "")
|
|
37
|
+
|
|
38
|
+
def stop_dispatcher
|
|
39
|
+
pid = Workers.at(@root).find(@worker)&.fetch("pid", nil)
|
|
40
|
+
return unless pid && Shared::Processes.alive?(pid)
|
|
41
|
+
|
|
42
|
+
puts "Stopping the dispatcher of #{@worker} (pid #{pid}). A running agent finishes first."
|
|
43
|
+
RunningProcesses.terminate(pid)
|
|
44
|
+
abort "maf: pid #{pid} did not stop. Stop it with: kill #{pid}" unless RunningProcesses.wait_for_exit(pid)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def release_tasks
|
|
48
|
+
env = { "COORD_ROLE" => role, "COORD_WORKER" => @worker }
|
|
49
|
+
ids = Team.coord(@root, "next", "--mine", env: env).scan(/^[0-9a-f-]{36}/)
|
|
50
|
+
ids.each { |id| release(id) }
|
|
51
|
+
ids.size
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def release(id)
|
|
55
|
+
Team.coord(@root, "unclaim", id)
|
|
56
|
+
Team.coord(@root, "annotate", id, "Worker #{@worker} was retired. Continue on the existing task branch.")
|
|
57
|
+
end
|
|
58
|
+
|
|
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
|
|
63
|
+
|
|
64
|
+
def remove_worker
|
|
65
|
+
remove_worktree
|
|
66
|
+
WorkerArchive.new(@root, @worker).run
|
|
67
|
+
remove_presence
|
|
68
|
+
Workers.at(@root).remove(@worker)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def dirty?
|
|
72
|
+
return false unless Dir.exist?(@dir)
|
|
73
|
+
|
|
74
|
+
lines = IO.popen(["git", "-C", @dir, "status", "--porcelain"], err: File::NULL, &:readlines)
|
|
75
|
+
lines.map { |line| line[3..].strip }.any? { |path| RUNTIME_PREFIXES.none? { |prefix| path.start_with?(prefix) } }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def remove_worktree
|
|
79
|
+
return unless Dir.exist?(@dir)
|
|
80
|
+
|
|
81
|
+
system("git", "-C", @root, "worktree", "remove", "--force", @dir, out: File::NULL) ||
|
|
82
|
+
warn("maf: could not remove #{@dir}. Remove it with: git worktree remove #{@dir}")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# RunningProcesses finds processes whose working directory is inside DIR.
|
|
87
|
+
# A harness session or a dispatcher runs in its worktree.
|
|
88
|
+
module RunningProcesses
|
|
89
|
+
def self.in(dir)
|
|
90
|
+
out = IO.popen(%w[lsof -a -d cwd -Fpn], err: File::NULL, &:read)
|
|
91
|
+
pairs(out).select { |_pid, path| path == dir || path.start_with?("#{dir}/") }.map(&:first) - [Process.pid]
|
|
92
|
+
rescue Errno::ENOENT
|
|
93
|
+
warn "maf: lsof not found. Cannot check if the worker still runs."
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# TERM lets a running agent finish its run first.
|
|
98
|
+
def self.terminate(pid)
|
|
99
|
+
Process.kill("TERM", pid)
|
|
100
|
+
rescue Errno::EPERM
|
|
101
|
+
abort "maf: pid #{pid} belongs to another user. Stop it with: sudo kill #{pid}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A dispatcher run can take up to its --timeout (default 1500 seconds).
|
|
105
|
+
def self.wait_for_exit(pid, timeout: Integer(ENV.fetch("MAF_STOP_TIMEOUT", "1800")))
|
|
106
|
+
deadline = Time.now + timeout
|
|
107
|
+
sleep 0.5 while Shared::Processes.alive?(pid) && Time.now < deadline
|
|
108
|
+
!Shared::Processes.alive?(pid)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.pairs(out)
|
|
112
|
+
out.lines(chomp: true).slice_before { |line| line.start_with?("p") }
|
|
113
|
+
.map { |group| [group.first[1..].to_i, group.find { |line| line.start_with?("n") }.to_s[1..].to_s] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# role_limits.rb - save the session limits of a role in .maf/config.json.
|
|
4
|
+
#
|
|
5
|
+
# maf worker restart frontend-developer_bot --max-context 80000
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
# RoleLimits reads the limit flags of `maf worker start|restart` and saves
|
|
10
|
+
# them under team.limits.<role> in .maf/config.json. The dispatcher reads
|
|
11
|
+
# them when it starts, so a restart applies them. A flag of
|
|
12
|
+
# `maf start --dispatch` still wins over a saved limit.
|
|
13
|
+
class RoleLimits
|
|
14
|
+
FLAGS = { "--max-context" => "max_context", "--max-session-runs" => "max_session_runs",
|
|
15
|
+
"--cache-window" => "cache_window" }.freeze
|
|
16
|
+
|
|
17
|
+
def self.parse(args) = FLAGS.filter_map { |flag, key| pair(args, flag, key) }.to_h
|
|
18
|
+
def self.pair(args, flag, key) = (index = args.index(flag)) && [key, number(flag, args[index + 1])]
|
|
19
|
+
|
|
20
|
+
def self.number(flag, text)
|
|
21
|
+
value = Integer(text.to_s, exception: false)
|
|
22
|
+
value && value >= 0 ? value : abort("maf: #{flag} needs a number of 0 or more")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(root) = @path = File.join(root, ".maf", "config.json")
|
|
26
|
+
|
|
27
|
+
def save(role, limits)
|
|
28
|
+
return if limits.empty?
|
|
29
|
+
|
|
30
|
+
File.write(@path, JSON.pretty_generate(merged(manifest, role, limits)))
|
|
31
|
+
puts "Saved limits of #{role}: #{limits.map { |key, value| "#{key}=#{value}" }.join(" ")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def merged(data, role, limits)
|
|
37
|
+
saved = data.dig("team", "limits").to_h
|
|
38
|
+
team = data.fetch("team", {}).merge("limits" => saved.merge(role => saved[role].to_h.merge(limits)))
|
|
39
|
+
data.merge("team" => team)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A corrupt manifest must not be overwritten. Stop with a clear message instead.
|
|
43
|
+
def manifest
|
|
44
|
+
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
abort "maf: #{@path} is not valid JSON. Fix it, then run this command again."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module SetupAgent
|
|
5
|
+
# Args reads the positional arguments (HARNESS ROLE[_WORKER] [model:M]),
|
|
6
|
+
# then the flags. --dispatch and --model M are setup_agent's own flags; all
|
|
7
|
+
# other flags go to the dispatcher and need --dispatch.
|
|
8
|
+
class Args
|
|
9
|
+
Parsed = Struct.new(:harness, :role, :worker_id, :worker, :model, :dispatch, :detach, :dispatcher_args,
|
|
10
|
+
keyword_init: true)
|
|
11
|
+
|
|
12
|
+
def self.parse(argv)
|
|
13
|
+
dispatch, detach, positional, flags = split(argv)
|
|
14
|
+
model = take_model(flags)
|
|
15
|
+
reject_stray(flags, dispatch)
|
|
16
|
+
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
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.split(argv)
|
|
21
|
+
rest = argv - %w[--dispatch --detach]
|
|
22
|
+
index = rest.index { |arg| arg.start_with?("--") } || rest.size
|
|
23
|
+
[argv.include?("--dispatch"), argv.include?("--detach"), rest[0...index], rest[index..-1]]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.identity(positional, model, dispatch)
|
|
27
|
+
harness, role_spec, model_arg = positional
|
|
28
|
+
abort_usage if harness.nil? || role_spec.nil?
|
|
29
|
+
role, worker_id = split_role(role_spec, dispatch ? "bot" : "1")
|
|
30
|
+
{ harness: harness, role: role, worker_id: worker_id, worker: "#{role}-#{worker_id}",
|
|
31
|
+
model: model || model_arg&.sub(/\Amodel:/, "") }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.take_model(flags)
|
|
35
|
+
index = flags.index("--model")
|
|
36
|
+
value = index && flags.slice!(index, 2)[1]
|
|
37
|
+
abort "setup_agent: --model needs a value" if index && (value.nil? || value.start_with?("--"))
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.reject_stray(flags, dispatch)
|
|
42
|
+
return if flags.empty? || dispatch
|
|
43
|
+
|
|
44
|
+
abort "setup_agent: #{flags.join(" ")} works only with --dispatch"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.split_role(spec, default_id = "1")
|
|
48
|
+
role, _sep, worker_id = spec.rpartition("_")
|
|
49
|
+
role.empty? ? [spec, default_id] : [role, worker_id]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.abort_usage
|
|
53
|
+
abort "usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--dispatch [FLAGS...]]"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module SetupAgent
|
|
5
|
+
# Dispatch runs dispatcher in the worktree instead of an interactive
|
|
6
|
+
# session. The dispatcher of the main checkout is used: maf update
|
|
7
|
+
# refreshes that copy, not the copy in a worktree. The current Ruby runs
|
|
8
|
+
# it, so an old system Ruby on the shebang path never parses it.
|
|
9
|
+
module Dispatch
|
|
10
|
+
DISPATCHER = ".maf/bin/dispatcher"
|
|
11
|
+
|
|
12
|
+
def self.launch(args, model)
|
|
13
|
+
dispatcher = File.join(Project.root, DISPATCHER)
|
|
14
|
+
require_dispatcher!(dispatcher)
|
|
15
|
+
cmd = [RbConfig.ruby, dispatcher, args.role, "--harness", args.harness]
|
|
16
|
+
cmd += ["--model", model] if model
|
|
17
|
+
cmd += args.dispatcher_args
|
|
18
|
+
args.detach ? detach(cmd, args.worker) : Launcher.exec_or_die(cmd)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# setsid gives the dispatcher its own session, so it keeps running when
|
|
22
|
+
# the terminal or the harness session that started it ends.
|
|
23
|
+
def self.detach(cmd, worker)
|
|
24
|
+
log = File.join(ENV.fetch("COORD_DIR"), "sessions", "#{worker}.log")
|
|
25
|
+
pid = spawn_detached(cmd, log)
|
|
26
|
+
Maf::Workers.at(Project.root).update(worker, "pid" => pid)
|
|
27
|
+
puts "Started #{worker} in the background (pid #{pid}). Log: #{log}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.spawn_detached(cmd, log)
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
32
|
+
io = { in: File::NULL, out: [log, "a"], err: %i[child out] }
|
|
33
|
+
pid = fork { Process.setsid && exec({ "DISPATCHER_LOG" => log, "PWD" => Dir.pwd }, *cmd, **io) }
|
|
34
|
+
Process.detach(pid) && pid
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.require_dispatcher!(dispatcher)
|
|
38
|
+
return if File.exist?(dispatcher)
|
|
39
|
+
|
|
40
|
+
abort "setup_agent: #{dispatcher} is missing. Run maf update in the main project, then start again."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module SetupAgent
|
|
5
|
+
module Launcher
|
|
6
|
+
# Hermes loads role files as skills from <hermes_dir>/<project>-<role>/.
|
|
7
|
+
# flow.rb writes the skill there; --skills <project>-<role> loads it.
|
|
8
|
+
# Hermes has no --agent flag; the skill's SKILL.md is the role definition.
|
|
9
|
+
# `-q` on a TTY seeds an interactive session with the prompt.
|
|
10
|
+
# A lead role owns no task, so it gets an inbox prompt. A role with
|
|
11
|
+
# can_edit false starts without the file-writing toolsets.
|
|
12
|
+
module Hermes
|
|
13
|
+
PROMPT = "Follow the coordination rules of your role skill as role %<role>s. " \
|
|
14
|
+
"Run coord inbox, then coord next --wait. Claim a task, do the work, finish it. Repeat."
|
|
15
|
+
LEAD_PROMPT = "Follow your role skill and its coordination rules as role %<role>s. " \
|
|
16
|
+
"You are a lead role. Never claim a task. Run coord inbox --wait and handle each message."
|
|
17
|
+
|
|
18
|
+
def self.launch(role, _worker, model)
|
|
19
|
+
cmd = %w[hermes chat]
|
|
20
|
+
cmd += ["--skills", HermesSkill.name(role)] if HermesSkill.installed?(role)
|
|
21
|
+
cmd += ["--model", model] if model
|
|
22
|
+
cmd += ["-t", Flow::READ_ONLY_TOOLSETS] if read_only?(role)
|
|
23
|
+
Launcher.exec_or_die(cmd + ["-q", prompt(role)])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.prompt(role) = format(Flow::LEADS.include?(role) ? LEAD_PROMPT : PROMPT, role: role)
|
|
27
|
+
|
|
28
|
+
def self.read_only?(role)
|
|
29
|
+
Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module SetupAgent
|
|
5
|
+
# HermesSkill resolves the skill flow.rb generated for a role: the name is
|
|
6
|
+
# <project>-<role>, and the dir is the manifest's hermes_dir (set by
|
|
7
|
+
# `flow.rb --hermes-dir`) or flow.rb's default ~/.hermes/skills.
|
|
8
|
+
module HermesSkill
|
|
9
|
+
DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
|
|
10
|
+
|
|
11
|
+
# Plain defs, not endless ones: this file must still parse on Ruby 2.x so
|
|
12
|
+
# the version check above can print its message.
|
|
13
|
+
def self.name(role)
|
|
14
|
+
"#{File.basename(Project.root)}-#{role}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.dir
|
|
18
|
+
Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.installed?(role)
|
|
22
|
+
File.exist?(File.join(dir, name(role), "SKILL.md"))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module SetupAgent
|
|
5
|
+
module Launcher
|
|
6
|
+
REGISTRY = {}
|
|
7
|
+
|
|
8
|
+
def self.for(harness)
|
|
9
|
+
register_defaults if REGISTRY.empty?
|
|
10
|
+
REGISTRY.fetch(harness) { abort "setup_agent: no launcher for harness '#{harness}' yet." }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.register_defaults
|
|
14
|
+
REGISTRY["claude"] = Claude
|
|
15
|
+
REGISTRY["opencode"] = Opencode
|
|
16
|
+
REGISTRY["codex"] = Codex
|
|
17
|
+
REGISTRY["hermes"] = Hermes
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.exec_or_die(cmd)
|
|
21
|
+
Kernel.exec(*cmd)
|
|
22
|
+
rescue Errno::ENOENT
|
|
23
|
+
abort "setup_agent: '#{cmd.first}' not found on PATH."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# FlowFiles finds the files of the flow in .maf/ of the main project. The
|
|
27
|
+
# project's own harness config stays as it is: each file of the flow is
|
|
28
|
+
# passed to the harness at the start.
|
|
29
|
+
module FlowFiles
|
|
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")]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module SetupAgent
|
|
5
|
+
class Manifest
|
|
6
|
+
def self.load(path)
|
|
7
|
+
abort_missing(path) unless File.exist?(path)
|
|
8
|
+
|
|
9
|
+
new(JSON.parse(File.read(path))["agents"])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.abort_missing(path)
|
|
13
|
+
abort "setup_agent: #{path} missing. Fix: maf add HARNESS:ROLE"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(agents)
|
|
17
|
+
@agents = agents
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def verify!(harness, role)
|
|
21
|
+
return if @agents.any? { |a| a["harness"] == harness && a["role"] == role }
|
|
22
|
+
|
|
23
|
+
abort "setup_agent: no #{harness}:#{role} in .maf/config.json.\n#{add_hint("#{harness}:#{role}")}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# maf add keeps the current agents, so the hint names only the missing agent.
|
|
27
|
+
def add_hint(missing) = " Fix: maf add #{missing}"
|
|
28
|
+
|
|
29
|
+
def model_for(harness, role)
|
|
30
|
+
entry = @agents.find { |a| a["harness"] == harness && a["role"] == role }
|
|
31
|
+
entry && entry["model"]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module SetupAgent
|
|
5
|
+
# A role file that is not committed yet is missing in a new worktree, and
|
|
6
|
+
# the harness then starts without its role. Copy the main checkout's file.
|
|
7
|
+
# The worktree also needs the symlink from the harness folder to the file.
|
|
8
|
+
# A harness folder with the project's own files gets one link per role file.
|
|
9
|
+
module RoleFile
|
|
10
|
+
def self.copy(root, dir, harness, role)
|
|
11
|
+
relative = Flow.role_path(harness, role)
|
|
12
|
+
return unless relative
|
|
13
|
+
|
|
14
|
+
copy_missing(File.join(root, relative), File.join(dir, relative))
|
|
15
|
+
link(Flow::AgentLinks.new(dir), harness)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.link(links, harness)
|
|
19
|
+
links.link_files(harness) if links.link(harness) == :refuse
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.copy_missing(source, dest)
|
|
23
|
+
return unless File.exist?(source) && !File.exist?(dest)
|
|
24
|
+
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
26
|
+
FileUtils.cp(source, dest)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../bootstrap"
|
|
4
|
+
|
|
5
|
+
module Maf
|
|
6
|
+
module SetupAgent
|
|
7
|
+
# Refresh owned hooks before the harness starts in an existing worktree.
|
|
8
|
+
module RuntimeHooks
|
|
9
|
+
def self.install(dir, harness)
|
|
10
|
+
%w[next-task.rb board-watch.rb session-guard.rb context-watch.rb].each { |name| copy(dir, name) }
|
|
11
|
+
configure(dir, harness)
|
|
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"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.copy(dir, name, relative = ".maf/coordination/harness-hooks/#{name}")
|
|
26
|
+
path = File.join(dir, relative)
|
|
27
|
+
abort "maf: #{path} is a foreign hook; the worker was not started" if foreign?(path, name)
|
|
28
|
+
|
|
29
|
+
Flow::HookFiles.copy(File.join(Flow::ASSETS, "harness-hooks", name), path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.foreign?(path, name)
|
|
33
|
+
File.exist?(path) && !File.read(path).include?("#{name} -")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../shared/project"
|
|
4
|
+
|
|
5
|
+
module Maf
|
|
6
|
+
module SetupAgent
|
|
7
|
+
class Worktree
|
|
8
|
+
COORD = ".maf/bin/coord"
|
|
9
|
+
|
|
10
|
+
# coord creates the worktree with the same shared formula.
|
|
11
|
+
def self.dir_for(root, slug) = Maf::Shared::Project.worktree_dir(root, slug)
|
|
12
|
+
|
|
13
|
+
# `coord worktree` is idempotent. Run it also for an existing worktree,
|
|
14
|
+
# so the worktree gets runtime files that were added after it was created.
|
|
15
|
+
# MAF_HARNESS tells coord which harness starts the worker, so coord checks
|
|
16
|
+
# the role file of that harness only.
|
|
17
|
+
def self.ensure(role, worker_id, harness)
|
|
18
|
+
dir = dir_for(Dir.pwd, "#{role}-#{worker_id}")
|
|
19
|
+
abort "setup_agent: coord worktree failed" unless coord_worktree(role, worker_id, harness)
|
|
20
|
+
new(dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.coord_worktree(role, worker_id, harness)
|
|
24
|
+
system({ "MAF_HARNESS" => harness }, RbConfig.ruby, COORD, "worktree", role, worker_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(dir)
|
|
28
|
+
@dir = dir
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def dir
|
|
32
|
+
@dir
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def export_env!
|
|
36
|
+
path = File.join(@dir, ".maf/env.sh")
|
|
37
|
+
File.readlines(path).each { |line| set_env(line) } if File.exist?(path)
|
|
38
|
+
ENV["PATH"] = [ENV["MAF_BIN"], ENV["PATH"]].compact.join(File::PATH_SEPARATOR)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def set_env(line)
|
|
44
|
+
return unless line.chomp =~ /\Aexport (\w+)=(.+)\z/
|
|
45
|
+
|
|
46
|
+
ENV[Regexp.last_match(1)] = Regexp.last_match(2)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|