maf 0.2.0 → 0.4.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 +59 -0
- data/README.md +22 -1
- data/assets/agents-contract.md +2 -0
- data/assets/coord +145 -21
- data/assets/dispatcher +67 -13
- data/install.md +6 -1
- data/lib/maf/backup.rb +63 -0
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/budget.rb +2 -0
- data/lib/maf/cli.rb +38 -10
- data/lib/maf/flow/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +2 -1
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/manifest.rb +1 -1
- data/lib/maf/flow/models.rb +11 -0
- data/lib/maf/flow/options.rb +29 -3
- data/lib/maf/flow/prompt_text.rb +14 -7
- data/lib/maf/flow/report.rb +6 -5
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +5 -5
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow.rb +3 -1
- data/lib/maf/menu.rb +48 -14
- data/lib/maf/restore.rb +62 -0
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +2 -2
- data/lib/maf/setup_agent/launcher.rb +6 -4
- data/lib/maf/setup_agent/manifest.rb +9 -3
- data/lib/maf/setup_agent.rb +11 -11
- data/lib/maf/shared/harness/defaults.rb +10 -3
- data/lib/maf/shared/harnesses/claude.rb +6 -3
- data/lib/maf/shared/harnesses/codex.rb +39 -10
- data/lib/maf/shared/harnesses/cursor.rb +60 -4
- data/lib/maf/shared/harnesses/hermes.rb +21 -11
- data/lib/maf/shared/harnesses/opencode.rb +25 -2
- data/lib/maf/shared/roles.rb +4 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +5 -1
- data/lib/maf/shared/sandbox.rb +6 -0
- data/lib/maf/team.rb +15 -6
- data/lib/maf/team_command.rb +11 -2
- data/lib/maf/untrack.rb +12 -1
- data/lib/maf/version.rb +1 -1
- data/lib/maf/worker_control.rb +12 -1
- data/lib/maf/worker_list.rb +83 -0
- data/templates/roles.yml +14 -0
- metadata +6 -1
data/assets/dispatcher
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# dispatcher polls the inbox and the task board on a fixed interval.
|
|
12
12
|
#
|
|
13
13
|
# Usage:
|
|
14
|
-
# dispatcher ROLE (--harness NAME | --command TEMPLATE) [--model M]
|
|
14
|
+
# dispatcher ROLE (--harness NAME | --command TEMPLATE) [--model M] [--effort L]
|
|
15
15
|
# [--skill NAME | --no-skill] [--interval S] [--max-turns N]
|
|
16
16
|
# [--timeout S] [--idle-timeout S] [--grace S]
|
|
17
17
|
# [--completion-signal TEXT] [--abort-signal TEXT]
|
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
# (claude, codex, cursor, hermes, opencode). Each one
|
|
32
32
|
# resumes sessions. --harness or --command is required.
|
|
33
33
|
# --command TEMPLATE Any other harness. A shell command with %{prompt},
|
|
34
|
-
# %{role}, %{skill}, and %{
|
|
34
|
+
# %{role}, %{skill}, %{model}, and %{effort} placeholders, each
|
|
35
35
|
# shell-escaped. The command must exit when done.
|
|
36
36
|
# Sessions are not resumed for custom commands.
|
|
37
37
|
# --model M Model for the agent, in the harness CLI's own format.
|
|
38
|
+
# --effort L Effort level of the model, in the harness CLI's own
|
|
39
|
+
# format (for example high). Default: the harness default.
|
|
38
40
|
# --skill NAME Hermes skill to preload. Default: <project>-<role> when
|
|
39
41
|
# flow.rb generated it.
|
|
40
42
|
# --no-skill Do not preload a skill.
|
|
@@ -99,7 +101,9 @@
|
|
|
99
101
|
# atomic, so a run never starts for a task that another worker took. If the
|
|
100
102
|
# same task set is still unclaimed afterwards, it waits with a doubling
|
|
101
103
|
# backoff (capped at MAX_BACKOFF) before it tries again. A changed task set
|
|
102
|
-
# dispatches at once.
|
|
104
|
+
# dispatches at once. After MAX_ATTEMPTS runs in a row that leave a task
|
|
105
|
+
# claimed and not done, the dispatcher blocks the task (`coord block --kind
|
|
106
|
+
# failing`), which escalates it. A usage limit or an unknown model counts no run.
|
|
103
107
|
#
|
|
104
108
|
# Sessions and the prompt cache: the session ID is saved to
|
|
105
109
|
# .maf/coordination/sessions/<worker>.session and resumed on the next run, so the
|
|
@@ -151,7 +155,7 @@ module Dispatcher
|
|
|
151
155
|
MAX_BACKOFF = 3600
|
|
152
156
|
REPORT_RETRIES = 1
|
|
153
157
|
|
|
154
|
-
Config = Struct.new(:role, :harness, :command, :model, :skill, :no_skill, :interval, :max_turns,
|
|
158
|
+
Config = Struct.new(:role, :harness, :command, :model, :effort, :skill, :no_skill, :interval, :max_turns,
|
|
155
159
|
:timeout, :idle_timeout, :grace, :completion_signal, :abort_signal,
|
|
156
160
|
:cache_window, :max_session_runs, :max_context, :once, :poll_tasks, :worker, :coord_dir,
|
|
157
161
|
:verbose, :lean,
|
|
@@ -171,6 +175,13 @@ module Dispatcher
|
|
|
171
175
|
end
|
|
172
176
|
end
|
|
173
177
|
|
|
178
|
+
def self.refuse_nested
|
|
179
|
+
return unless Maf::Shared::Sandbox.nested?
|
|
180
|
+
|
|
181
|
+
Log.line(Maf::Shared::Sandbox::NESTED.delete_prefix("maf: "))
|
|
182
|
+
exit 1
|
|
183
|
+
end
|
|
184
|
+
|
|
174
185
|
# Project finds the main checkout from any worktree. Lead roles own no
|
|
175
186
|
# tasks: they get an inbox prompt and no task polling.
|
|
176
187
|
Project = Maf::Shared::Project
|
|
@@ -318,6 +329,49 @@ module Dispatcher
|
|
|
318
329
|
end
|
|
319
330
|
end
|
|
320
331
|
|
|
332
|
+
# FailureLimit counts the runs in a row that left a task claimed and not
|
|
333
|
+
# done. At MAX_ATTEMPTS it blocks the task, which escalates it. The count is in
|
|
334
|
+
# .maf/coordination/failures/<id>, so a restart keeps it.
|
|
335
|
+
class FailureLimit
|
|
336
|
+
NOTICE = "Task %<id>s is blocked: %<runs>d runs of %<worker>s in a row ended without coord done. " \
|
|
337
|
+
"Last result: %<last>s Fix the cause, then run `coord unblock %<id>s`."
|
|
338
|
+
UNCOUNTED = %i[limited broken].freeze
|
|
339
|
+
|
|
340
|
+
def initialize(config, poller, coord = ->(*args) { CoordCall.run(Main.run_env(config), *args) })
|
|
341
|
+
@config = config
|
|
342
|
+
@poller = poller
|
|
343
|
+
@coord = coord
|
|
344
|
+
@dir = File.join(config.coord_dir, "failures")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def track(ids, outcome)
|
|
348
|
+
return if UNCOUNTED.include?(outcome)
|
|
349
|
+
|
|
350
|
+
open = outcome == :waiting ? [] : @poller.claimed_task_ids
|
|
351
|
+
ids.each { |id| open.include?(id) ? count(id) : clear(id) }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def runs(id) = File.exist?(path(id)) ? File.read(path(id)).to_i : 0
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
def count(id)
|
|
359
|
+
runs = runs(id) + 1
|
|
360
|
+
runs >= MAX_ATTEMPTS ? block(id, runs) : save(id, runs)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def block(id, runs)
|
|
364
|
+
text = format(NOTICE, id: id, runs: runs, worker: @config.worker, last: Log.last.to_s[0, 200])
|
|
365
|
+
Log.line("task #{id}: #{runs} unfinished runs in a row; blocking it")
|
|
366
|
+
@coord.call("block", "--kind", "failing", id, text)
|
|
367
|
+
clear(id)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def save(id, runs) = FileUtils.mkdir_p(@dir) && File.write(path(id), runs.to_s)
|
|
371
|
+
def clear(id) = FileUtils.rm_f(path(id))
|
|
372
|
+
def path(id) = File.join(@dir, id)
|
|
373
|
+
end
|
|
374
|
+
|
|
321
375
|
# Lessons reads the dead ends and corrections of graphify-out/reflections/
|
|
322
376
|
# LESSONS.md for one task, newest first. coord done and coord lesson save the
|
|
323
377
|
# notes that LESSONS.md sums up. A lesson stays when its note cites a node of
|
|
@@ -764,10 +818,11 @@ module Dispatcher
|
|
|
764
818
|
module Generic
|
|
765
819
|
extend Maf::Shared::Harness::Run
|
|
766
820
|
|
|
767
|
-
PLACEHOLDERS = %i[prompt role skill model].freeze
|
|
821
|
+
PLACEHOLDERS = %i[prompt role skill model effort].freeze
|
|
768
822
|
|
|
769
823
|
def self.build_command(config, prompt, _session_id)
|
|
770
|
-
values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model
|
|
824
|
+
values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model,
|
|
825
|
+
effort: config.effort }
|
|
771
826
|
line = PLACEHOLDERS.reduce(config.command) { |cmd, key| cmd.gsub("%{#{key}}") { values[key].to_s.shellescape } }
|
|
772
827
|
["sh", "-c", line]
|
|
773
828
|
end
|
|
@@ -906,7 +961,7 @@ module Dispatcher
|
|
|
906
961
|
def started(session_id, runs)
|
|
907
962
|
save("mode" => "dispatch", "role" => @config.role, "harness" => @config.command ? "command" : @config.harness,
|
|
908
963
|
"pid" => Process.pid, "running" => true, "run_started_at" => now, "session_id" => session_id,
|
|
909
|
-
"session_runs" => runs, "model" => @config.model || read["model"])
|
|
964
|
+
"session_runs" => runs, "model" => @config.model || read["model"], "effort" => @config.effort)
|
|
910
965
|
end
|
|
911
966
|
|
|
912
967
|
def finished(outcome, detail, model)
|
|
@@ -1309,6 +1364,7 @@ module Dispatcher
|
|
|
1309
1364
|
Log.line("usage limit: no new run until #{@pause.until.utc}")
|
|
1310
1365
|
end
|
|
1311
1366
|
def chores = @chores ||= LeadChores.new(@poller, @config.role)
|
|
1367
|
+
def failure_limit = @failure_limit ||= FailureLimit.new(@config, @poller)
|
|
1312
1368
|
|
|
1313
1369
|
def start
|
|
1314
1370
|
Log.line("started: role=#{@config.role} #{harness_label} #{timing_label}")
|
|
@@ -1337,11 +1393,8 @@ module Dispatcher
|
|
|
1337
1393
|
# unknown model returns them without an attempt.
|
|
1338
1394
|
SETTLE = { failed: :release, limited: :restore, broken: :restore }.freeze
|
|
1339
1395
|
|
|
1340
|
-
# Tasks still unclaimed after a message run count as dispatched, so the
|
|
1341
|
-
# next cycle backs off instead of starting another run for them.
|
|
1342
1396
|
def settle(messages, outcome)
|
|
1343
1397
|
@mailbox.public_send(SETTLE.fetch(outcome, :ack), messages)
|
|
1344
|
-
@watch.dispatched(@poller.unclaimed_task_ids) if @config.poll_tasks
|
|
1345
1398
|
true
|
|
1346
1399
|
end
|
|
1347
1400
|
|
|
@@ -1350,7 +1403,7 @@ module Dispatcher
|
|
|
1350
1403
|
ids = @poller.claimed_task_ids
|
|
1351
1404
|
return false unless @resume.due?(ids)
|
|
1352
1405
|
|
|
1353
|
-
dispatch(Prompt.resume(@config.role, ids) + context(ids.first))
|
|
1406
|
+
failure_limit.track(ids, dispatch(Prompt.resume(@config.role, ids) + context(ids.first)))
|
|
1354
1407
|
@resume.dispatched(ids)
|
|
1355
1408
|
true
|
|
1356
1409
|
end
|
|
@@ -1366,7 +1419,7 @@ module Dispatcher
|
|
|
1366
1419
|
end
|
|
1367
1420
|
|
|
1368
1421
|
def run_task(id)
|
|
1369
|
-
dispatch(Prompt.task(@config.role, id) + context(id))
|
|
1422
|
+
failure_limit.track([id], dispatch(Prompt.task(@config.role, id) + context(id)))
|
|
1370
1423
|
true
|
|
1371
1424
|
end
|
|
1372
1425
|
end
|
|
@@ -1374,7 +1427,7 @@ module Dispatcher
|
|
|
1374
1427
|
# Options parses the command line into a Config.
|
|
1375
1428
|
module Options
|
|
1376
1429
|
VALUES = { "--harness NAME" => [:harness, String], "--command TEMPLATE" => [:command, String],
|
|
1377
|
-
"--model M" => [:model, String], "--skill NAME" => [:skill, String],
|
|
1430
|
+
"--model M" => [:model, String], "--effort L" => [:effort, String], "--skill NAME" => [:skill, String],
|
|
1378
1431
|
"--interval S" => [:interval, Integer], "--max-turns N" => [:max_turns, Integer],
|
|
1379
1432
|
"--timeout S" => [:timeout, Integer], "--cache-window S" => [:cache_window, Integer],
|
|
1380
1433
|
"--max-session-runs N" => [:max_session_runs, Integer], "--max-context N" => [:max_context, Integer],
|
|
@@ -1466,5 +1519,6 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1466
1519
|
ENV["PATH"] = [File.dirname(File.expand_path(__FILE__)), ENV["PATH"]].compact.join(File::PATH_SEPARATOR)
|
|
1467
1520
|
# Each run inherits the git persona of the agents.
|
|
1468
1521
|
Maf::Shared::GitIdentity.apply(Dispatcher::Project.manifest)
|
|
1522
|
+
Dispatcher.refuse_nested
|
|
1469
1523
|
Dispatcher::Main.new(Dispatcher::Options.parse(ARGV)).run
|
|
1470
1524
|
end
|
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
|
|
data/lib/maf/backup.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# backup.rb - copies of the flow config in the git directory, and maf restore.
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
# Backup keeps copies of the files in .maf/ that maf update cannot make again.
|
|
10
|
+
# Each copy is a folder in .git/maf/backup/, so no checkout, merge, or reset
|
|
11
|
+
# changes it. A copy is made only when config.json exists and the files differ
|
|
12
|
+
# from the newest copy.
|
|
13
|
+
class Backup
|
|
14
|
+
FILES = %w[config.json roles.yml workflow.md sandbox.json].freeze
|
|
15
|
+
KEEP = 10
|
|
16
|
+
MISSING = "maf: .maf/config.json is missing. A backup from %<time>s has it. Run: maf restore"
|
|
17
|
+
|
|
18
|
+
def initialize(root)
|
|
19
|
+
@root = root
|
|
20
|
+
@dir = git_dir&.then { |dir| File.join(dir, "maf", "backup") }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def save
|
|
24
|
+
files = current
|
|
25
|
+
return if @dir.nil? || !files.key?("config.json") || files == read(latest)
|
|
26
|
+
|
|
27
|
+
write(File.join(@dir, Time.now.utc.strftime("%Y%m%dT%H%M%SZ")), files)
|
|
28
|
+
prune
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def snapshots = @dir ? Dir.glob(File.join(@dir, "*")).select { |path| File.directory?(path) }.sort : []
|
|
32
|
+
def latest = snapshots.last
|
|
33
|
+
def find(name) = snapshots.find { |path| File.basename(path) == name }
|
|
34
|
+
def read(dir) = dir ? FILES.to_h { |name| [name, file(dir, name)] }.compact : {}
|
|
35
|
+
def time(dir) = Time.strptime(File.basename(dir), "%Y%m%dT%H%M%S%z").localtime.strftime("%Y-%m-%d %H:%M")
|
|
36
|
+
|
|
37
|
+
def warn_missing
|
|
38
|
+
maf = File.join(@root, ".maf")
|
|
39
|
+
return if !Dir.exist?(maf) || File.exist?(File.join(maf, "config.json")) || !read(latest).key?("config.json")
|
|
40
|
+
|
|
41
|
+
warn format(MISSING, time: time(latest))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def current = read(File.join(@root, ".maf"))
|
|
47
|
+
def file(dir, name) = File.file?(File.join(dir, name)) ? File.read(File.join(dir, name)) : nil
|
|
48
|
+
|
|
49
|
+
def write(dir, files)
|
|
50
|
+
FileUtils.mkdir_p(dir)
|
|
51
|
+
files.each { |name, text| File.write(File.join(dir, name), text) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def prune = snapshots[0...-KEEP].each { |dir| FileUtils.rm_rf(dir) }
|
|
55
|
+
|
|
56
|
+
def git_dir
|
|
57
|
+
out, status = Open3.capture2("git", "-C", @root, "rev-parse", "--git-common-dir", err: File::NULL)
|
|
58
|
+
status.success? ? File.expand_path(out.strip, @root) : nil
|
|
59
|
+
rescue Errno::ENOENT
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -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
|
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/budget.rb
CHANGED
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 "restore"
|
|
16
18
|
require_relative "vm"
|
|
17
19
|
require_relative "version"
|
|
18
20
|
|
|
@@ -21,6 +23,7 @@ module Maf
|
|
|
21
23
|
|
|
22
24
|
def self.flow(*args) = Flow::Generator.new(["--project", Dir.pwd, *args]).run
|
|
23
25
|
def self.role_names = Flow::RoleCatalog.new(Dir.pwd).roles.keys
|
|
26
|
+
def self.role_effort(role) = Flow::RoleCatalog.new(Dir.pwd).roles.dig(role, "effort")
|
|
24
27
|
|
|
25
28
|
def self.agents = File.exist?(MANIFEST) ? JSON.parse(File.read(MANIFEST)).fetch("agents") : []
|
|
26
29
|
def self.agent_specs = agents.map { |a| "#{a["harness"]}:#{a["role"]}" }
|
|
@@ -38,23 +41,26 @@ module Maf
|
|
|
38
41
|
|
|
39
42
|
class CLI
|
|
40
43
|
COMMANDS = {
|
|
41
|
-
"add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] add agents and install
|
|
42
|
-
"without arguments it asks",
|
|
44
|
+
"add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] [--effort ROLE=LEVEL] add agents and install " \
|
|
45
|
+
"the flow; without arguments it asks",
|
|
43
46
|
"remove" => "HARNESS:ROLE ... remove agents",
|
|
44
47
|
"update" => " regenerate the files of the current agents",
|
|
45
48
|
"agents" => " list the current agents",
|
|
46
49
|
"roles" => " list the built-in roles and the project roles",
|
|
47
50
|
"role" => "add NAME add a stub role to .maf/roles.yml",
|
|
48
|
-
"start" => "[HARNESS ROLE[_WORKER]] [--dispatch [--detach]] start one agent in its worktree",
|
|
49
|
-
"prepare" => "HARNESS ROLE[_WORKER] [--dispatch|--interactive] [--replace W]
|
|
51
|
+
"start" => "[HARNESS ROLE[_WORKER]] [--effort LEVEL] [--dispatch [--detach]] start one agent in its worktree",
|
|
52
|
+
"prepare" => "HARNESS ROLE[_WORKER] [--effort LEVEL] [--dispatch|--interactive] [--replace W] " \
|
|
53
|
+
"prepare a worker; " \
|
|
50
54
|
"--dispatch starts it (default for the architect)",
|
|
51
55
|
"retire" => "ROLE[_WORKER] remove a worker; its tasks return to the pool",
|
|
52
|
-
"worker" => "status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N]
|
|
53
|
-
"[--cache-window S]
|
|
56
|
+
"worker" => "list | status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N] " \
|
|
57
|
+
"[--max-session-runs N] [--cache-window S] list the running workers, or control one worker; " \
|
|
58
|
+
"start and restart save the session limits of the role",
|
|
54
59
|
"team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
|
|
55
60
|
"uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
|
|
56
61
|
"migrate" => "[--check] [--yes] move an old-layout install into .maf/",
|
|
57
62
|
"untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
|
|
63
|
+
"restore" => "[--list] [BACKUP] [--force] put the flow config back from a backup in .git/maf/backup",
|
|
58
64
|
"vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
|
|
59
65
|
"menu" => " interactive mode (also: maf without a command)",
|
|
60
66
|
"guide" => " print the setup guide for an AI agent (install.md)",
|
|
@@ -72,11 +78,26 @@ module Maf
|
|
|
72
78
|
abort "maf: unknown command '#{@command}'. Run: maf help" unless COMMANDS.key?(@command)
|
|
73
79
|
abort "maf: #{Migrate::HINT}" if old_layout?
|
|
74
80
|
|
|
75
|
-
send("run_#{@command}")
|
|
81
|
+
backed_up { send("run_#{@command}") }
|
|
76
82
|
end
|
|
77
83
|
|
|
84
|
+
NO_BACKUP = %w[version guide roles].freeze
|
|
85
|
+
|
|
78
86
|
private
|
|
79
87
|
|
|
88
|
+
def backed_up
|
|
89
|
+
backup = checked_backup unless NO_BACKUP.include?(@command)
|
|
90
|
+
yield
|
|
91
|
+
ensure
|
|
92
|
+
backup&.save
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def checked_backup
|
|
96
|
+
backup = Backup.new(SetupAgent::Project.root)
|
|
97
|
+
backup.warn_missing unless @command == "restore"
|
|
98
|
+
backup.tap(&:save)
|
|
99
|
+
end
|
|
100
|
+
|
|
80
101
|
# Every command except these needs the new layout.
|
|
81
102
|
# maf uninstall --check previews the migration on an old layout.
|
|
82
103
|
def old_layout?
|
|
@@ -95,7 +116,10 @@ module Maf
|
|
|
95
116
|
# Without arguments on a terminal, maf add asks for the harness, the roles, and the models.
|
|
96
117
|
def run_add = @args.empty? && $stdin.tty? ? Menu.new.add : Maf.flow(*AgentArgs.convert(@args, "--agent"))
|
|
97
118
|
def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
|
|
98
|
-
def run_update
|
|
119
|
+
def run_update
|
|
120
|
+
Maf.flow(*@args)
|
|
121
|
+
Untrack.warn_tracked(SetupAgent::Project.root)
|
|
122
|
+
end
|
|
99
123
|
def run_roles = Maf.flow("--list-roles")
|
|
100
124
|
|
|
101
125
|
def run_role
|
|
@@ -108,13 +132,15 @@ module Maf
|
|
|
108
132
|
def run_prepare = Prepare.new(@args).run
|
|
109
133
|
def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
|
|
110
134
|
def run_worker
|
|
135
|
+
return WorkerList.new(SetupAgent::Project.root).run if @args.first == "list"
|
|
136
|
+
|
|
111
137
|
spec = @args[1] || abort(worker_usage)
|
|
112
138
|
limits = RoleLimits.parse(@args)
|
|
113
139
|
WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
|
|
114
140
|
.run(@args[0].to_s)
|
|
115
141
|
end
|
|
116
142
|
|
|
117
|
-
def worker_usage = "usage: maf worker #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
|
|
143
|
+
def worker_usage = "usage: maf worker list | #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
|
|
118
144
|
"[--max-context N] [--max-session-runs N] [--cache-window S]"
|
|
119
145
|
def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
|
|
120
146
|
def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
@@ -123,6 +149,7 @@ module Maf
|
|
|
123
149
|
def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
|
|
124
150
|
def run_version = puts("maf #{VERSION}")
|
|
125
151
|
def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
|
|
152
|
+
def run_restore = Restore.new(SetupAgent::Project.root, @args).run
|
|
126
153
|
def run_migrate = Migrate::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
127
154
|
|
|
128
155
|
def run_agents
|
|
@@ -132,7 +159,8 @@ module Maf
|
|
|
132
159
|
end
|
|
133
160
|
|
|
134
161
|
def agent_line(agent)
|
|
135
|
-
|
|
162
|
+
effort = agent["effort"] && "effort=#{agent["effort"]}"
|
|
163
|
+
"#{agent["harness"]}:#{agent["role"]}".ljust(32) + [agent["model"], effort].compact.join(" ")
|
|
136
164
|
end
|
|
137
165
|
end
|
|
138
166
|
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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/manifest.rb
CHANGED
|
@@ -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/models.rb
CHANGED
|
@@ -21,6 +21,17 @@ module Maf
|
|
|
21
21
|
|
|
22
22
|
def self.known?(harness, model) = Harness[harness]&.known_model?(model, self.for(harness)) != false
|
|
23
23
|
def self.default(harness) = Harness[harness]&.default_model
|
|
24
|
+
# Without a model, the levels of the default model of the harness.
|
|
25
|
+
def self.efforts(harness, model) = Harness[harness]&.efforts(model || default(harness))
|
|
26
|
+
|
|
27
|
+
# The level of the role if the harness lists it. A model that runs only
|
|
28
|
+
# with a level gets the lowest level instead.
|
|
29
|
+
def self.default_effort(harness, level, model)
|
|
30
|
+
levels = efforts(harness, model).to_a
|
|
31
|
+
return level if levels.include?(level)
|
|
32
|
+
|
|
33
|
+
levels.first if Harness[harness]&.effort_required?(model)
|
|
34
|
+
end
|
|
24
35
|
end
|
|
25
36
|
end
|
|
26
37
|
end
|
data/lib/maf/flow/options.rb
CHANGED
|
@@ -4,8 +4,8 @@ module Maf
|
|
|
4
4
|
module Flow
|
|
5
5
|
# Options parses the command line and holds the settings of one run.
|
|
6
6
|
class Options
|
|
7
|
-
attr_accessor :project, :agents
|
|
8
|
-
attr_reader :removed, :models, :hermes_dir, :list_roles
|
|
7
|
+
attr_accessor :project, :agents, :role_efforts
|
|
8
|
+
attr_reader :removed, :models, :efforts, :hermes_dir, :list_roles
|
|
9
9
|
|
|
10
10
|
# Each flag: the OptionParser arguments, and a block that runs in the
|
|
11
11
|
# Options instance with the flag value.
|
|
@@ -14,6 +14,7 @@ module Maf
|
|
|
14
14
|
[["--agent SPEC", "HARNESS:ROLE[:MODEL]"], ->(v) { @agents << parse_agent(v) }],
|
|
15
15
|
[["--remove SPEC", "HARNESS:ROLE"], ->(v) { @removed << parse_agent(v) }],
|
|
16
16
|
[["--model PAIR", "ROLE=MODEL"], ->(v) { @models.store(*v.split("=", 2)) }],
|
|
17
|
+
[["--effort PAIR", "ROLE=LEVEL"], ->(v) { @efforts.store(*v.split("=", 2)) }],
|
|
17
18
|
[["--hermes-dir DIR"], ->(v) { @hermes_dir = File.expand_path(v) }],
|
|
18
19
|
[["--check"], ->(_) { @check = true }],
|
|
19
20
|
[["--force"], ->(_) { @force = true }],
|
|
@@ -22,7 +23,8 @@ module Maf
|
|
|
22
23
|
].freeze
|
|
23
24
|
|
|
24
25
|
def initialize(argv)
|
|
25
|
-
@argv, @agents, @removed, @models = argv, [], [], {}
|
|
26
|
+
@argv, @agents, @removed, @models, @efforts = argv, [], [], {}, {}
|
|
27
|
+
@role_efforts = {}
|
|
26
28
|
@check = @force = @list_roles = false
|
|
27
29
|
@bootstrap = true
|
|
28
30
|
end
|
|
@@ -48,8 +50,32 @@ module Maf
|
|
|
48
50
|
agent[:model] || @models[agent[:role]] || agent[:saved_model] || Harness[agent[:harness]]&.default_model
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
# An empty --effort ROLE= clears the level: the harness default applies.
|
|
54
|
+
# A saved level belongs to the saved model, so a new model gets the role default.
|
|
55
|
+
def effort_for(agent)
|
|
56
|
+
flag = effort_flag(agent)
|
|
57
|
+
return (flag unless flag.empty?) if flag
|
|
58
|
+
|
|
59
|
+
(agent[:saved_effort] unless model_changed?(agent)) ||
|
|
60
|
+
Models.default_effort(agent[:harness], @role_efforts[agent[:role]], model_for(agent))
|
|
61
|
+
end
|
|
62
|
+
|
|
51
63
|
private
|
|
52
64
|
|
|
65
|
+
# --effort ROLE=LEVEL applies to the agents of this run. Without one of
|
|
66
|
+
# the role, it applies to the saved agents of the role.
|
|
67
|
+
def effort_flag(agent)
|
|
68
|
+
role = agent[:role]
|
|
69
|
+
@efforts[role] if run_agent?(agent) || @agents.none? { |a| a[:role] == role && run_agent?(a) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def run_agent?(agent) = agent.key?(:model)
|
|
73
|
+
|
|
74
|
+
def model_changed?(agent)
|
|
75
|
+
chosen = agent[:model] || @models[agent[:role]]
|
|
76
|
+
chosen && chosen != agent[:saved_model]
|
|
77
|
+
end
|
|
78
|
+
|
|
53
79
|
def parser
|
|
54
80
|
OptionParser.new do |o|
|
|
55
81
|
o.banner = "Usage: maf add|remove|update [HARNESS:ROLE ...] [options]"
|