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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +22 -1
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/coord +145 -21
  6. data/assets/dispatcher +67 -13
  7. data/install.md +6 -1
  8. data/lib/maf/backup.rb +63 -0
  9. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  10. data/lib/maf/bootstrap.rb +4 -0
  11. data/lib/maf/budget.rb +2 -0
  12. data/lib/maf/cli.rb +38 -10
  13. data/lib/maf/flow/effort_report.rb +20 -0
  14. data/lib/maf/flow/generator.rb +2 -1
  15. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  16. data/lib/maf/flow/manifest.rb +1 -1
  17. data/lib/maf/flow/models.rb +11 -0
  18. data/lib/maf/flow/options.rb +29 -3
  19. data/lib/maf/flow/prompt_text.rb +14 -7
  20. data/lib/maf/flow/report.rb +6 -5
  21. data/lib/maf/flow/role_catalog.rb +1 -1
  22. data/lib/maf/flow/role_files.rb +5 -5
  23. data/lib/maf/flow/roster.rb +6 -3
  24. data/lib/maf/flow.rb +3 -1
  25. data/lib/maf/menu.rb +48 -14
  26. data/lib/maf/restore.rb +62 -0
  27. data/lib/maf/setup_agent/args.rb +16 -10
  28. data/lib/maf/setup_agent/dispatch.rb +2 -2
  29. data/lib/maf/setup_agent/launcher.rb +6 -4
  30. data/lib/maf/setup_agent/manifest.rb +9 -3
  31. data/lib/maf/setup_agent.rb +11 -11
  32. data/lib/maf/shared/harness/defaults.rb +10 -3
  33. data/lib/maf/shared/harnesses/claude.rb +6 -3
  34. data/lib/maf/shared/harnesses/codex.rb +39 -10
  35. data/lib/maf/shared/harnesses/cursor.rb +60 -4
  36. data/lib/maf/shared/harnesses/hermes.rb +21 -11
  37. data/lib/maf/shared/harnesses/opencode.rb +25 -2
  38. data/lib/maf/shared/roles.rb +4 -0
  39. data/lib/maf/shared/sandbox/seatbelt.rb +5 -1
  40. data/lib/maf/shared/sandbox.rb +6 -0
  41. data/lib/maf/team.rb +15 -6
  42. data/lib/maf/team_command.rb +11 -2
  43. data/lib/maf/untrack.rb +12 -1
  44. data/lib/maf/version.rb +1 -1
  45. data/lib/maf/worker_control.rb +12 -1
  46. data/lib/maf/worker_list.rb +83 -0
  47. data/templates/roles.yml +14 -0
  48. 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 %{model} placeholders, each
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. Then continue.
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 in the settings
6
- # file of the flow, .maf/claude/settings.json. `maf start` passes the file
7
- # with --settings. Claude Code runs these hooks next to the project's own.
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
- missing = missing_hooks(settings)
21
- missing.each { |event, hook| add_hook(settings, event, hook) }
22
- write(file, settings) unless missing.empty?
23
- !missing.empty?
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) && missing_hooks(read(file)).empty? ? :skip : :configure_claude_hook
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
@@ -44,6 +44,8 @@ module Maf
44
44
  if @max && count > @max
45
45
  end
46
46
 
47
+ def limited? = !@max.nil?
48
+
47
49
  def allowed?(harness, model)
48
50
  @allow.empty? || @allow.any? { |spec| [harness, "#{harness}:#{model}"].include?(spec) }
49
51
  end
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 the flow; " \
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] prepare a worker; " \
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] [--max-session-runs N] " \
53
- "[--cache-window S] control one worker; start and restart save the session limits of the role",
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 = Maf.flow(*@args)
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
- "#{agent["harness"]}:#{agent["role"]}".ljust(32) + agent["model"].to_s
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
@@ -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
@@ -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").
@@ -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
@@ -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]"