maf 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +174 -0
- data/README.md +29 -11
- data/assets/agents-contract.md +2 -0
- data/assets/analyst +29 -51
- data/assets/coord +168 -41
- data/assets/dashboard +1 -5
- data/assets/dispatcher +127 -298
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/install.md +6 -1
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/cli.rb +22 -9
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +3 -2
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +3 -3
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +14 -38
- data/lib/maf/flow/options.rb +33 -5
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +13 -14
- data/lib/maf/flow/report.rb +8 -9
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +7 -22
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +7 -21
- data/lib/maf/menu.rb +49 -15
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +3 -3
- data/lib/maf/setup_agent/launcher.rb +16 -65
- data/lib/maf/setup_agent/manifest.rb +9 -3
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +11 -13
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +147 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +137 -0
- data/lib/maf/shared/harnesses/codex.rb +202 -0
- data/lib/maf/shared/harnesses/cursor.rb +120 -0
- data/lib/maf/shared/harnesses/hermes.rb +122 -0
- data/lib/maf/shared/harnesses/opencode.rb +192 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/roles.rb +4 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team.rb +5 -4
- data/lib/maf/team_command.rb +5 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +12 -25
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +16 -8
- data/lib/maf/worker_list.rb +83 -0
- data/lib/maf/workers.rb +27 -0
- data/templates/roles.yml +14 -0
- metadata +33 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- data/templates/opencode.md.erb +0 -24
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
|
|
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]
|
|
@@ -19,20 +19,24 @@
|
|
|
19
19
|
# [--no-poll-tasks]
|
|
20
20
|
# [--full-harness] [--once] [--verbose]
|
|
21
21
|
#
|
|
22
|
-
# dispatcher tester
|
|
22
|
+
# dispatcher tester --harness hermes
|
|
23
23
|
# dispatcher reviewer --harness claude --model sonnet
|
|
24
24
|
# dispatcher backend-developer --harness opencode --model openrouter/qwen3-coder
|
|
25
25
|
# dispatcher reviewer --harness codex
|
|
26
|
-
# dispatcher reviewer --
|
|
26
|
+
# dispatcher reviewer --harness cursor --model gpt-5
|
|
27
|
+
# dispatcher reviewer --harness claude --once # one poll cycle, then exit
|
|
27
28
|
#
|
|
28
29
|
# Flags:
|
|
29
|
-
# --harness NAME
|
|
30
|
-
# opencode. Each one
|
|
30
|
+
# --harness NAME A built-in harness: one file in .maf/lib/maf/shared/harnesses/
|
|
31
|
+
# (claude, codex, cursor, hermes, opencode). Each one
|
|
32
|
+
# resumes sessions. --harness or --command is required.
|
|
31
33
|
# --command TEMPLATE Any other harness. A shell command with %{prompt},
|
|
32
|
-
# %{role}, %{skill}, and %{
|
|
34
|
+
# %{role}, %{skill}, %{model}, and %{effort} placeholders, each
|
|
33
35
|
# shell-escaped. The command must exit when done.
|
|
34
36
|
# Sessions are not resumed for custom commands.
|
|
35
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.
|
|
36
40
|
# --skill NAME Hermes skill to preload. Default: <project>-<role> when
|
|
37
41
|
# flow.rb generated it.
|
|
38
42
|
# --no-skill Do not preload a skill.
|
|
@@ -53,7 +57,8 @@
|
|
|
53
57
|
# --abort-signal TEXT The agent prints TEXT when it gives up. The run is a
|
|
54
58
|
# failure. Default: off.
|
|
55
59
|
# --cache-window S Resume a session only if its last run ended less than
|
|
56
|
-
# S seconds ago. Default:
|
|
60
|
+
# S seconds ago. Default: the cache window of the harness
|
|
61
|
+
# (codex: 300), else 3300
|
|
57
62
|
# (55 minutes). 0 means never resume.
|
|
58
63
|
# --max-session-runs N Start a fresh session after N runs in one session
|
|
59
64
|
# (default: 5). Each resume sends the whole old context
|
|
@@ -96,7 +101,9 @@
|
|
|
96
101
|
# atomic, so a run never starts for a task that another worker took. If the
|
|
97
102
|
# same task set is still unclaimed afterwards, it waits with a doubling
|
|
98
103
|
# backoff (capped at MAX_BACKOFF) before it tries again. A changed task set
|
|
99
|
-
# 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.
|
|
100
107
|
#
|
|
101
108
|
# Sessions and the prompt cache: the session ID is saved to
|
|
102
109
|
# .maf/coordination/sessions/<worker>.session and resumed on the next run, so the
|
|
@@ -136,6 +143,8 @@ begin
|
|
|
136
143
|
require_relative "../lib/maf/shared/roles"
|
|
137
144
|
require_relative "../lib/maf/shared/peak_rate"
|
|
138
145
|
require_relative "../lib/maf/shared/git_identity"
|
|
146
|
+
require_relative "../lib/maf/shared/harness"
|
|
147
|
+
require_relative "../lib/maf/shared/sandbox"
|
|
139
148
|
rescue LoadError
|
|
140
149
|
abort "dispatcher: the maf shared library is missing. Run maf update."
|
|
141
150
|
end
|
|
@@ -146,7 +155,7 @@ module Dispatcher
|
|
|
146
155
|
MAX_BACKOFF = 3600
|
|
147
156
|
REPORT_RETRIES = 1
|
|
148
157
|
|
|
149
|
-
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,
|
|
150
159
|
:timeout, :idle_timeout, :grace, :completion_signal, :abort_signal,
|
|
151
160
|
:cache_window, :max_session_runs, :max_context, :once, :poll_tasks, :worker, :coord_dir,
|
|
152
161
|
:verbose, :lean,
|
|
@@ -171,29 +180,6 @@ module Dispatcher
|
|
|
171
180
|
Project = Maf::Shared::Project
|
|
172
181
|
LEADS = Maf::Shared::Roles::LEADS
|
|
173
182
|
|
|
174
|
-
# FlowConfig finds the MCP config of the flow in .maf/mcp/ (next to the
|
|
175
|
-
# coordination folder). The project's .mcp.json and opencode.json stay as
|
|
176
|
-
# they are. NOTE: lib/maf/setup_agent/launcher.rb passes the same files.
|
|
177
|
-
module FlowConfig
|
|
178
|
-
def self.path(config, name) = File.join(File.dirname(config.coord_dir), "mcp", name)
|
|
179
|
-
def self.claude_mcp(config) = File.exist?(file = path(config, "claude.json")) ? ["--mcp-config", file] : []
|
|
180
|
-
|
|
181
|
-
def self.opencode_env(config)
|
|
182
|
-
file = path(config, "opencode.json")
|
|
183
|
-
File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# EditGrant reads each agent's can_edit from .maf/config.json. On Hermes,
|
|
188
|
-
# a role with can_edit false starts without the file-writing toolsets.
|
|
189
|
-
module EditGrant
|
|
190
|
-
READ_ONLY_TOOLSETS = Maf::Shared::Roles::READ_ONLY_TOOLSETS
|
|
191
|
-
|
|
192
|
-
def self.read_only?(role)
|
|
193
|
-
Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
183
|
# Presence writes .maf/coordination/presence/<worker>.json once per cycle, so
|
|
198
184
|
# `coord who` shows the dispatcher as live while its pid runs.
|
|
199
185
|
module Presence
|
|
@@ -210,17 +196,6 @@ module Dispatcher
|
|
|
210
196
|
end
|
|
211
197
|
end
|
|
212
198
|
|
|
213
|
-
# HermesSkill resolves the skill flow.rb generated for a role: the name is
|
|
214
|
-
# <project>-<role>, and the dir is the manifest's hermes_dir (set by
|
|
215
|
-
# `flow.rb --hermes-dir`) or flow.rb's default ~/.hermes/skills.
|
|
216
|
-
module HermesSkill
|
|
217
|
-
DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
|
|
218
|
-
|
|
219
|
-
def self.name(role) = "#{File.basename(Project.root)}-#{role}"
|
|
220
|
-
def self.dir = Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
|
|
221
|
-
def self.installed?(role) = File.exist?(File.join(dir, name(role), "SKILL.md"))
|
|
222
|
-
end
|
|
223
|
-
|
|
224
199
|
# Session persists the agent's session ID across runs, per worker: two
|
|
225
200
|
# dispatchers for one role (different COORD_WORKER values) never share a
|
|
226
201
|
# session or a handoff note. The session file's mtime is the time of the
|
|
@@ -347,6 +322,49 @@ module Dispatcher
|
|
|
347
322
|
end
|
|
348
323
|
end
|
|
349
324
|
|
|
325
|
+
# FailureLimit counts the runs in a row that left a task claimed and not
|
|
326
|
+
# done. At MAX_ATTEMPTS it blocks the task, which escalates it. The count is in
|
|
327
|
+
# .maf/coordination/failures/<id>, so a restart keeps it.
|
|
328
|
+
class FailureLimit
|
|
329
|
+
NOTICE = "Task %<id>s is blocked: %<runs>d runs of %<worker>s in a row ended without coord done. " \
|
|
330
|
+
"Last result: %<last>s Fix the cause, then run `coord unblock %<id>s`."
|
|
331
|
+
UNCOUNTED = %i[limited broken].freeze
|
|
332
|
+
|
|
333
|
+
def initialize(config, poller, coord = ->(*args) { CoordCall.run(Main.run_env(config), *args) })
|
|
334
|
+
@config = config
|
|
335
|
+
@poller = poller
|
|
336
|
+
@coord = coord
|
|
337
|
+
@dir = File.join(config.coord_dir, "failures")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def track(ids, outcome)
|
|
341
|
+
return if UNCOUNTED.include?(outcome)
|
|
342
|
+
|
|
343
|
+
open = outcome == :waiting ? [] : @poller.claimed_task_ids
|
|
344
|
+
ids.each { |id| open.include?(id) ? count(id) : clear(id) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def runs(id) = File.exist?(path(id)) ? File.read(path(id)).to_i : 0
|
|
348
|
+
|
|
349
|
+
private
|
|
350
|
+
|
|
351
|
+
def count(id)
|
|
352
|
+
runs = runs(id) + 1
|
|
353
|
+
runs >= MAX_ATTEMPTS ? block(id, runs) : save(id, runs)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def block(id, runs)
|
|
357
|
+
text = format(NOTICE, id: id, runs: runs, worker: @config.worker, last: Log.last.to_s[0, 200])
|
|
358
|
+
Log.line("task #{id}: #{runs} unfinished runs in a row; blocking it")
|
|
359
|
+
@coord.call("block", "--kind", "failing", id, text)
|
|
360
|
+
clear(id)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def save(id, runs) = FileUtils.mkdir_p(@dir) && File.write(path(id), runs.to_s)
|
|
364
|
+
def clear(id) = FileUtils.rm_f(path(id))
|
|
365
|
+
def path(id) = File.join(@dir, id)
|
|
366
|
+
end
|
|
367
|
+
|
|
350
368
|
# Lessons reads the dead ends and corrections of graphify-out/reflections/
|
|
351
369
|
# LESSONS.md for one task, newest first. coord done and coord lesson save the
|
|
352
370
|
# notes that LESSONS.md sums up. A lesson stays when its note cites a node of
|
|
@@ -532,10 +550,7 @@ module Dispatcher
|
|
|
532
550
|
# CoordCall runs coord with this Ruby, so an old system Ruby on the
|
|
533
551
|
# shebang path never parses it.
|
|
534
552
|
module CoordCall
|
|
535
|
-
def self.command
|
|
536
|
-
local = File.expand_path(COORD_BIN)
|
|
537
|
-
[RbConfig.ruby, File.executable?(local) ? local : File.join(Project.root, COORD_BIN)]
|
|
538
|
-
end
|
|
553
|
+
def self.command = [RbConfig.ruby, File.join(Project.root, COORD_BIN)]
|
|
539
554
|
|
|
540
555
|
# Returns true when the command succeeds.
|
|
541
556
|
def self.run(env, *args)
|
|
@@ -676,10 +691,25 @@ module Dispatcher
|
|
|
676
691
|
|
|
677
692
|
def self.run(cmd, env:, timeout:, limits: Limits.new(0, GRACE))
|
|
678
693
|
reader, writer = IO.pipe
|
|
679
|
-
pid =
|
|
694
|
+
pid = start(env, cmd, writer)
|
|
680
695
|
collect(pid, Stream.new(reader.tap { writer.close }), Watch.new(timeout, limits))
|
|
681
|
-
|
|
682
|
-
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def self.start(env, cmd, writer)
|
|
699
|
+
ready, done = IO.pipe
|
|
700
|
+
pid = fork { session_exec(env, cmd, writer, done) }
|
|
701
|
+
done.close
|
|
702
|
+
ready.read && ready.close
|
|
703
|
+
pid
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def self.session_exec(env, cmd, writer, done)
|
|
707
|
+
Process.setsid
|
|
708
|
+
done.close
|
|
709
|
+
exec(env, *cmd, in: File::NULL, out: writer, err: writer)
|
|
710
|
+
rescue StandardError => e
|
|
711
|
+
writer.write(e.message)
|
|
712
|
+
exit!(127)
|
|
683
713
|
end
|
|
684
714
|
|
|
685
715
|
def self.collect(pid, stream, watch)
|
|
@@ -704,55 +734,16 @@ module Dispatcher
|
|
|
704
734
|
end
|
|
705
735
|
end
|
|
706
736
|
|
|
707
|
-
|
|
708
|
-
# its session ID from one specific event, never from free text, so a
|
|
709
|
-
# prompt or a handoff note that the agent echoes cannot supply a false ID.
|
|
710
|
-
module Events
|
|
711
|
-
def self.all(output) = output.lines.filter_map { |line| parse(line.strip) }
|
|
712
|
-
|
|
713
|
-
def self.last(output, &match) = all(output).reverse.find(&match) || {}
|
|
737
|
+
Events = Maf::Shared::Harness::Events
|
|
714
738
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
end
|
|
721
|
-
end
|
|
722
|
-
|
|
723
|
-
# TokenUsage reads the token usage of one run and adds it to the totals of
|
|
724
|
-
# the worker in .maf/coordination/usage/<worker>.json. Claude Code, Hermes,
|
|
725
|
-
# Codex, and opencode report usage. A run without usage data adds nothing. A usage error
|
|
726
|
-
# never fails a run.
|
|
727
|
-
#
|
|
728
|
-
# input_tokens counts every input token, cache reads included.
|
|
729
|
-
# cached_input_tokens counts the cache reads, which cost a fraction of the
|
|
730
|
-
# input price. cache_write_input_tokens counts the cache writes, which cost
|
|
731
|
-
# more than the input price. A resume with a large cache write found a cold
|
|
732
|
-
# cache: the --cache-window is too long for the harness. Claude Code reports
|
|
733
|
-
# cache reads and cache writes outside its input_tokens, so they are added.
|
|
734
|
-
# Codex reports them inside.
|
|
739
|
+
# TokenUsage adds the token usage of one run to the totals of the worker in
|
|
740
|
+
# .maf/coordination/usage/<worker>.json. Each harness reads the usage of its
|
|
741
|
+
# run (Maf::Shared::Harness::Usage gives it one shape). A run without usage
|
|
742
|
+
# data adds nothing. A usage error never fails a run. A resume with a large
|
|
743
|
+
# cache write found a cold cache: the --cache-window is too long for the harness.
|
|
735
744
|
class TokenUsage
|
|
736
|
-
def self.normalize(data)
|
|
737
|
-
return nil unless data.is_a?(Hash)
|
|
738
|
-
|
|
739
|
-
{ "input_tokens" => input(data),
|
|
740
|
-
"cached_input_tokens" => int(data, "cached_input_tokens", "cache_read_input_tokens"),
|
|
741
|
-
"cache_write_input_tokens" => int(data, "cache_write_input_tokens", "cache_creation_input_tokens"),
|
|
742
|
-
"output_tokens" => int(data, "output_tokens", "completion_tokens") }
|
|
743
|
-
end
|
|
744
|
-
|
|
745
|
-
def self.input(data)
|
|
746
|
-
int(data, "input_tokens", "prompt_tokens") + int(data, "cache_read_input_tokens") +
|
|
747
|
-
int(data, "cache_creation_input_tokens")
|
|
748
|
-
end
|
|
749
|
-
|
|
750
|
-
def self.int(data, *names) = data.values_at(*names).compact.first.to_i
|
|
751
|
-
|
|
752
745
|
def self.label(usage) = usage.map { |key, value| "#{key.delete_suffix("_tokens")}=#{value}" }.join(" ")
|
|
753
746
|
|
|
754
|
-
def self.sum(list) = list.empty? ? nil : list.reduce { |total, usage| total.merge(usage) { |_, a, b| a + b } }
|
|
755
|
-
|
|
756
747
|
def initialize(coord_dir, worker)
|
|
757
748
|
@path = File.join(coord_dir, "usage", "#{worker}.json")
|
|
758
749
|
end
|
|
@@ -802,205 +793,40 @@ module Dispatcher
|
|
|
802
793
|
"cache_window" => @config.cache_window }
|
|
803
794
|
end
|
|
804
795
|
|
|
805
|
-
# PeakRate tells when a dispatched
|
|
806
|
-
#
|
|
796
|
+
# PeakRate tells when a dispatched run uses a model at its peak rate
|
|
797
|
+
# (Maf::Shared::PeakRate). The notice can show on a Chinese holiday.
|
|
807
798
|
module PeakRate
|
|
808
|
-
def self.deepseek?(config) = config.harness == "opencode" && OpencodeModel.for(config).to_s.include?("deepseek")
|
|
809
|
-
|
|
810
799
|
def self.notice(config, time = Time.now.utc)
|
|
811
|
-
return unless Maf::Shared::PeakRate.peak?(time) &&
|
|
800
|
+
return unless Maf::Shared::PeakRate.peak?(time) && Harness.for(config).peak_rate?(config)
|
|
812
801
|
|
|
813
802
|
"info: DeepSeek peak hours (#{time.strftime("%H:%M")} UTC): this run costs 2x the off-peak rate"
|
|
814
803
|
end
|
|
815
804
|
end
|
|
816
805
|
|
|
817
|
-
#
|
|
818
|
-
#
|
|
819
|
-
|
|
820
|
-
CONFIG_MODEL = /^\s*"model"\s*:\s*"([^"]+)"/
|
|
821
|
-
AGENT_MODEL = /\A---\n(?:(?!^---$).)*?^model:\s*(\S+)/m
|
|
822
|
-
|
|
823
|
-
def self.for(config) = config.model || agent(config.role) || configured
|
|
824
|
-
def self.agent(role) = find(File.join(".opencode", "agents", "#{role}.md"), AGENT_MODEL)
|
|
825
|
-
def self.configured = config_paths.lazy.filter_map { |path| find(path, CONFIG_MODEL) }.first
|
|
826
|
-
def self.config_paths = %w[opencode.json opencode.jsonc] + user_paths
|
|
827
|
-
def self.user_paths = %w[opencode.jsonc opencode.json].map { |name| File.join(home, name) }
|
|
828
|
-
def self.home = File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "opencode")
|
|
829
|
-
def self.find(path, pattern) = File.exist?(path) ? File.read(path)[pattern, 1] : nil
|
|
830
|
-
end
|
|
831
|
-
|
|
806
|
+
# Harness picks the harness of a run. Each built-in harness is one file in
|
|
807
|
+
# lib/maf/shared/harnesses/ (installed in .maf/lib/maf/shared/harnesses/).
|
|
808
|
+
# Generic runs a user-provided shell template.
|
|
832
809
|
module Harness
|
|
833
|
-
# Adapter is the part every built-in adapter shares. Each adapter sets
|
|
834
|
-
# STALE_SESSION to the error its CLI prints for an unknown session ID.
|
|
835
|
-
module Adapter
|
|
836
|
-
def stale_session?(output) = output.include?(self::STALE_SESSION)
|
|
837
|
-
def resume(session_id, *flag) = session_id ? [*flag, session_id] : []
|
|
838
|
-
def present(value) = value.to_s.empty? ? nil : value
|
|
839
|
-
def usage(_output) = nil
|
|
840
|
-
def context(_output) = nil
|
|
841
|
-
def model(_output) = nil
|
|
842
|
-
end
|
|
843
|
-
|
|
844
|
-
# Hermes: `hermes chat --oneshot`, JSONL output, skill preload.
|
|
845
|
-
module Hermes
|
|
846
|
-
extend Adapter
|
|
847
|
-
STALE_SESSION = "Session not found"
|
|
848
|
-
|
|
849
|
-
def self.build_command(config, prompt, session_id)
|
|
850
|
-
cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q] + options(config)
|
|
851
|
-
cmd += ["--max-turns", config.max_turns.to_s, "--run-budget", run_budget(config).to_s]
|
|
852
|
-
cmd + resume(session_id, "--resume") + ["-q", prompt]
|
|
853
|
-
end
|
|
854
|
-
|
|
855
|
-
def self.options(config)
|
|
856
|
-
tools = EditGrant.read_only?(config.role) ? ["-t", EditGrant::READ_ONLY_TOOLSETS] : []
|
|
857
|
-
[*(["--skills", config.skill] if config.skill), *(["--model", config.model] if config.model), *tools]
|
|
858
|
-
end
|
|
859
|
-
|
|
860
|
-
# Stop Hermes before the dispatcher's kill, so it can still write its result event.
|
|
861
|
-
def self.run_budget(config) = [config.timeout - Spawn::GRACE, 1].max
|
|
862
|
-
def self.result_event(output) = Events.last(output) { |event| event["type"] == "result" }
|
|
863
|
-
def self.result_text(output) = result_event(output)["text"]
|
|
864
|
-
def self.session_id(output) = present(result_event(output)["session_id"])
|
|
865
|
-
def self.usage(output) = TokenUsage.normalize(result_event(output)["usage"])
|
|
866
|
-
end
|
|
867
|
-
|
|
868
|
-
# Claude Code: `claude -p` prints one JSON object with session_id and
|
|
869
|
-
# result. `--agent` loads .claude/agents/<role>.md as the session's role.
|
|
870
|
-
# `--mcp-config` takes many values. Put `--` before the prompt, or claude
|
|
871
|
-
# reads the prompt as one more config file and exits.
|
|
872
|
-
module Claude
|
|
873
|
-
extend Adapter
|
|
874
|
-
STALE_SESSION = "No conversation found with session ID"
|
|
875
|
-
|
|
876
|
-
# LEAN starts no MCP server outside --mcp-config and lists no skill.
|
|
877
|
-
# The user's CLAUDE.md and hooks stay.
|
|
878
|
-
LEAN = %w[--strict-mcp-config --disable-slash-commands].freeze
|
|
879
|
-
|
|
880
|
-
def self.build_command(config, prompt, session_id)
|
|
881
|
-
cmd = %w[claude -p --output-format json --permission-mode bypassPermissions --agent] + [config.role]
|
|
882
|
-
cmd += ["--model", config.model] if config.model
|
|
883
|
-
cmd += LEAN if config.lean
|
|
884
|
-
cmd + FlowConfig.claude_mcp(config) + resume(session_id, "--resume") + ["--", prompt]
|
|
885
|
-
end
|
|
886
|
-
|
|
887
|
-
def self.result_event(output) = Events.last(output) { |event| event.key?("result") }
|
|
888
|
-
def self.result_text(output) = result_event(output)["result"]
|
|
889
|
-
def self.session_id(output) = present(result_event(output)["session_id"])
|
|
890
|
-
def self.usage(output) = TokenUsage.normalize(result_event(output)["usage"])
|
|
891
|
-
# The result names each model that the run used. The main model comes first.
|
|
892
|
-
def self.model(output) = result_event(output)["modelUsage"]&.keys&.first
|
|
893
|
-
end
|
|
894
|
-
|
|
895
|
-
# Codex: `codex exec` (or `codex exec resume ID`) with JSONL events. Codex
|
|
896
|
-
# has no agent flag, so a fresh session gets .codex/prompts/<role>.md
|
|
897
|
-
# in front of the prompt. A resumed session already has it.
|
|
898
|
-
module Codex
|
|
899
|
-
extend Adapter
|
|
900
|
-
STALE_SESSION = "no rollout found for thread id"
|
|
901
|
-
|
|
902
|
-
def self.build_command(config, prompt, session_id)
|
|
903
|
-
cmd = %w[codex exec] + (session_id ? ["resume"] : [])
|
|
904
|
-
cmd += %w[--json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox]
|
|
905
|
-
cmd += ["-m", config.model] if config.model
|
|
906
|
-
cmd += Lean.flags if config.lean
|
|
907
|
-
cmd + resume(session_id) + [session_id ? prompt : role_prompt(config.role) + prompt]
|
|
908
|
-
end
|
|
909
|
-
|
|
910
|
-
# Lean drops the parts of the user's Codex setup that a one-shot run
|
|
911
|
-
# does not use. Each one adds tool schemas, skill lists, or MCP calls
|
|
912
|
-
# to every model call. Only the graphify server of this project stays.
|
|
913
|
-
module Lean
|
|
914
|
-
FEATURES = %w[plugins apps browser_use computer_use multi_agent].freeze
|
|
915
|
-
SERVER = /^\[mcp_servers\.([\w-]+)\]\s*$/
|
|
916
|
-
|
|
917
|
-
def self.flags = FEATURES.flat_map { |name| ["--disable", name] } + servers.flat_map { |name| off(name) }
|
|
918
|
-
def self.off(name) = ["-c", "mcp_servers.#{name}.enabled=false"]
|
|
919
|
-
def self.servers = File.exist?(config_path) ? File.read(config_path).scan(SERVER).flatten.uniq - [keep] : []
|
|
920
|
-
def self.keep = "graphify-#{File.basename(Project.root)}"
|
|
921
|
-
def self.config_path = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
|
|
922
|
-
end
|
|
923
|
-
|
|
924
|
-
def self.role_prompt(role)
|
|
925
|
-
path = File.join(".codex", "prompts", "#{role}.md")
|
|
926
|
-
File.exist?(path) ? "#{File.read(path)}\n\n" : ""
|
|
927
|
-
end
|
|
928
|
-
|
|
929
|
-
def self.result_text(output)
|
|
930
|
-
Events.last(output) { |event| event.dig("item", "type") == "agent_message" }.dig("item", "text")
|
|
931
|
-
end
|
|
932
|
-
|
|
933
|
-
def self.session_id(output)
|
|
934
|
-
present(Events.last(output) { |event| event["type"] == "thread.started" }["thread_id"])
|
|
935
|
-
end
|
|
936
|
-
|
|
937
|
-
# Codex reports the usage of each turn in a turn.completed event.
|
|
938
|
-
def self.usage(output)
|
|
939
|
-
turns = Events.all(output).select { |event| event["type"] == "turn.completed" }
|
|
940
|
-
TokenUsage.sum(turns.filter_map { |event| TokenUsage.normalize(event["usage"]) })
|
|
941
|
-
end
|
|
942
|
-
end
|
|
943
|
-
|
|
944
|
-
# opencode: `opencode run --format json`. `--agent` loads
|
|
945
|
-
# .opencode/agents/<role>.md. The result event shape is not verified, so
|
|
946
|
-
# the log shows no result text. opencode prints terminal escape codes and
|
|
947
|
-
# can glue two JSON objects onto one line, so the session ID is matched on
|
|
948
|
-
# the start of an event object (captured from a real run), not by parsing
|
|
949
|
-
# whole lines.
|
|
950
|
-
module Opencode
|
|
951
|
-
extend Adapter
|
|
952
|
-
STALE_SESSION = "Session not found"
|
|
953
|
-
EVENT_SESSION = /\{"type":"[\w.-]+","timestamp":\d+,"sessionID":"([^"]+)"/
|
|
954
|
-
|
|
955
|
-
def self.session_id(output) = output.scan(EVENT_SESSION).flatten.last
|
|
956
|
-
|
|
957
|
-
def self.build_command(config, prompt, session_id)
|
|
958
|
-
cmd = %w[opencode run --format json --agent] + [config.role]
|
|
959
|
-
cmd += ["-m", config.model] if config.model
|
|
960
|
-
cmd + resume(session_id, "--session") + [prompt]
|
|
961
|
-
end
|
|
962
|
-
|
|
963
|
-
def self.result_text(_output) = nil
|
|
964
|
-
|
|
965
|
-
# Each model call ends with a step_finish event. Its tokens.input
|
|
966
|
-
# excludes the cache reads and writes; reasoning is billed as output.
|
|
967
|
-
STEP_FINISH = /\{"type":"step_finish".*/
|
|
968
|
-
|
|
969
|
-
def self.steps(output) = output.scan(STEP_FINISH).filter_map { |json| Events.parse(json)&.dig("part", "tokens") }
|
|
970
|
-
def self.usage(output) = TokenUsage.sum(steps(output).map { |tokens| TokenUsage.normalize(flat(tokens)) })
|
|
971
|
-
def self.context(output) = steps(output).last&.then { |tokens| TokenUsage.input(flat(tokens)) }
|
|
972
|
-
|
|
973
|
-
def self.flat(tokens)
|
|
974
|
-
{ "input_tokens" => tokens["input"].to_i, "cache_read_input_tokens" => tokens.dig("cache", "read").to_i,
|
|
975
|
-
"cache_creation_input_tokens" => tokens.dig("cache", "write").to_i,
|
|
976
|
-
"output_tokens" => tokens["output"].to_i + tokens["reasoning"].to_i }
|
|
977
|
-
end
|
|
978
|
-
end
|
|
979
|
-
|
|
980
810
|
# Generic: a user-provided shell template. No session resume, no parsing.
|
|
981
811
|
module Generic
|
|
982
|
-
|
|
812
|
+
extend Maf::Shared::Harness::Run
|
|
813
|
+
|
|
814
|
+
PLACEHOLDERS = %i[prompt role skill model effort].freeze
|
|
983
815
|
|
|
984
816
|
def self.build_command(config, prompt, _session_id)
|
|
985
|
-
values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model
|
|
817
|
+
values = { prompt: prompt, role: config.role, skill: config.skill, model: config.model,
|
|
818
|
+
effort: config.effort }
|
|
986
819
|
line = PLACEHOLDERS.reduce(config.command) { |cmd, key| cmd.gsub("%{#{key}}") { values[key].to_s.shellescape } }
|
|
987
820
|
["sh", "-c", line]
|
|
988
821
|
end
|
|
989
|
-
|
|
990
|
-
def self.stale_session?(_output) = false
|
|
991
|
-
def self.session_id(_output) = nil
|
|
992
|
-
def self.result_text(_output) = nil
|
|
993
|
-
def self.usage(_output) = nil
|
|
994
|
-
def self.context(_output) = nil
|
|
995
|
-
def self.model(_output) = nil
|
|
996
822
|
end
|
|
997
823
|
|
|
998
|
-
REGISTRY = { "hermes" => Hermes, "claude" => Claude, "codex" => Codex, "opencode" => Opencode }.freeze
|
|
999
|
-
|
|
1000
824
|
def self.for(config)
|
|
1001
825
|
return Generic if config.command
|
|
1002
826
|
|
|
1003
|
-
|
|
827
|
+
Maf::Shared::Harness[config.harness] ||
|
|
828
|
+
abort("dispatcher: unknown harness '#{config.harness}' (use #{Maf::Shared::Harness.names.join(", ")}). " \
|
|
829
|
+
"For another harness, use --command.")
|
|
1004
830
|
end
|
|
1005
831
|
end
|
|
1006
832
|
|
|
@@ -1060,7 +886,13 @@ module Dispatcher
|
|
|
1060
886
|
command = Project.manifest["verify"].to_s
|
|
1061
887
|
return true if command.empty? || LEADS.include?(config.role)
|
|
1062
888
|
|
|
1063
|
-
|
|
889
|
+
sandbox = Maf::Shared::Sandbox
|
|
890
|
+
run = -> { Spawn.run(sandboxed(command, config), env: env, timeout: config.timeout) }
|
|
891
|
+
check(sandbox.keeping_links(Harness::Generic, coord_dir: config.coord_dir, &run), command)
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def self.sandboxed(command, config)
|
|
895
|
+
Maf::Shared::Sandbox.wrap(["sh", "-c", command], Harness::Generic, coord_dir: config.coord_dir)
|
|
1064
896
|
end
|
|
1065
897
|
|
|
1066
898
|
def self.check(result, command)
|
|
@@ -1122,7 +954,7 @@ module Dispatcher
|
|
|
1122
954
|
def started(session_id, runs)
|
|
1123
955
|
save("mode" => "dispatch", "role" => @config.role, "harness" => @config.command ? "command" : @config.harness,
|
|
1124
956
|
"pid" => Process.pid, "running" => true, "run_started_at" => now, "session_id" => session_id,
|
|
1125
|
-
"session_runs" => runs, "model" => @config.model || read["model"])
|
|
957
|
+
"session_runs" => runs, "model" => @config.model || read["model"], "effort" => @config.effort)
|
|
1126
958
|
end
|
|
1127
959
|
|
|
1128
960
|
def finished(outcome, detail, model)
|
|
@@ -1256,10 +1088,14 @@ module Dispatcher
|
|
|
1256
1088
|
spawn(@harness.build_command(@config, text, session_id))
|
|
1257
1089
|
end
|
|
1258
1090
|
|
|
1091
|
+
def sandboxed(cmd) = Maf::Shared::Sandbox.wrap(cmd, @harness, coord_dir: @config.coord_dir)
|
|
1092
|
+
def keeping_links(&block) = Maf::Shared::Sandbox.keeping_links(@harness, coord_dir: @config.coord_dir, &block)
|
|
1093
|
+
|
|
1259
1094
|
def session_label(id) = id ? "#{id}, idle #{(@session.idle_seconds / 60).round}m" : "new"
|
|
1260
1095
|
|
|
1261
1096
|
def spawn(cmd)
|
|
1262
|
-
|
|
1097
|
+
limits = Limits.from(@config)
|
|
1098
|
+
result = keeping_links { Spawn.run(sandboxed(cmd), env: @env, timeout: @config.timeout, limits: limits) }
|
|
1263
1099
|
record(@harness.usage(result.output), result.output)
|
|
1264
1100
|
@model = @harness.model(result.output) || @model
|
|
1265
1101
|
result
|
|
@@ -1458,7 +1294,7 @@ module Dispatcher
|
|
|
1458
1294
|
env = { "COORD_DIR" => config.coord_dir, "COORD_ROLE" => config.role, "COORD_WORKER" => config.worker,
|
|
1459
1295
|
"COORD_DISPATCHED" => "1", "PWD" => Dir.pwd }
|
|
1460
1296
|
taskrc = File.join(config.coord_dir, "taskrc")
|
|
1461
|
-
env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {},
|
|
1297
|
+
env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {}, Harness.for(config).run_env(config))
|
|
1462
1298
|
end
|
|
1463
1299
|
|
|
1464
1300
|
def run
|
|
@@ -1521,9 +1357,11 @@ module Dispatcher
|
|
|
1521
1357
|
Log.line("usage limit: no new run until #{@pause.until.utc}")
|
|
1522
1358
|
end
|
|
1523
1359
|
def chores = @chores ||= LeadChores.new(@poller, @config.role)
|
|
1360
|
+
def failure_limit = @failure_limit ||= FailureLimit.new(@config, @poller)
|
|
1524
1361
|
|
|
1525
1362
|
def start
|
|
1526
1363
|
Log.line("started: role=#{@config.role} #{harness_label} #{timing_label}")
|
|
1364
|
+
Log.line("sandbox: #{Maf::Shared::Sandbox.describe(Harness.for(@config), coord_dir: @config.coord_dir)}")
|
|
1527
1365
|
@mailbox.recover
|
|
1528
1366
|
@stop = StopSignal.new
|
|
1529
1367
|
end
|
|
@@ -1548,11 +1386,8 @@ module Dispatcher
|
|
|
1548
1386
|
# unknown model returns them without an attempt.
|
|
1549
1387
|
SETTLE = { failed: :release, limited: :restore, broken: :restore }.freeze
|
|
1550
1388
|
|
|
1551
|
-
# Tasks still unclaimed after a message run count as dispatched, so the
|
|
1552
|
-
# next cycle backs off instead of starting another run for them.
|
|
1553
1389
|
def settle(messages, outcome)
|
|
1554
1390
|
@mailbox.public_send(SETTLE.fetch(outcome, :ack), messages)
|
|
1555
|
-
@watch.dispatched(@poller.unclaimed_task_ids) if @config.poll_tasks
|
|
1556
1391
|
true
|
|
1557
1392
|
end
|
|
1558
1393
|
|
|
@@ -1561,7 +1396,7 @@ module Dispatcher
|
|
|
1561
1396
|
ids = @poller.claimed_task_ids
|
|
1562
1397
|
return false unless @resume.due?(ids)
|
|
1563
1398
|
|
|
1564
|
-
dispatch(Prompt.resume(@config.role, ids) + context(ids.first))
|
|
1399
|
+
failure_limit.track(ids, dispatch(Prompt.resume(@config.role, ids) + context(ids.first)))
|
|
1565
1400
|
@resume.dispatched(ids)
|
|
1566
1401
|
true
|
|
1567
1402
|
end
|
|
@@ -1577,7 +1412,7 @@ module Dispatcher
|
|
|
1577
1412
|
end
|
|
1578
1413
|
|
|
1579
1414
|
def run_task(id)
|
|
1580
|
-
dispatch(Prompt.task(@config.role, id) + context(id))
|
|
1415
|
+
failure_limit.track([id], dispatch(Prompt.task(@config.role, id) + context(id)))
|
|
1581
1416
|
true
|
|
1582
1417
|
end
|
|
1583
1418
|
end
|
|
@@ -1585,7 +1420,7 @@ module Dispatcher
|
|
|
1585
1420
|
# Options parses the command line into a Config.
|
|
1586
1421
|
module Options
|
|
1587
1422
|
VALUES = { "--harness NAME" => [:harness, String], "--command TEMPLATE" => [:command, String],
|
|
1588
|
-
"--model M" => [:model, String], "--skill NAME" => [:skill, String],
|
|
1423
|
+
"--model M" => [:model, String], "--effort L" => [:effort, String], "--skill NAME" => [:skill, String],
|
|
1589
1424
|
"--interval S" => [:interval, Integer], "--max-turns N" => [:max_turns, Integer],
|
|
1590
1425
|
"--timeout S" => [:timeout, Integer], "--cache-window S" => [:cache_window, Integer],
|
|
1591
1426
|
"--max-session-runs N" => [:max_session_runs, Integer], "--max-context N" => [:max_context, Integer],
|
|
@@ -1595,7 +1430,7 @@ module Dispatcher
|
|
|
1595
1430
|
SWITCHES = { "--no-skill" => [:no_skill, true], "--no-poll-tasks" => [:poll_tasks, false],
|
|
1596
1431
|
"--full-harness" => [:lean, false],
|
|
1597
1432
|
"--once" => [:once, true], "--verbose" => [:verbose, true] }.freeze
|
|
1598
|
-
DEFAULTS = {
|
|
1433
|
+
DEFAULTS = { interval: 60, idle_timeout: 0, grace: Spawn::GRACE,
|
|
1599
1434
|
poll_tasks: true, lean: true,
|
|
1600
1435
|
no_skill: false, once: false, verbose: false }.freeze
|
|
1601
1436
|
|
|
@@ -1603,16 +1438,15 @@ module Dispatcher
|
|
|
1603
1438
|
DEFAULT_SESSION_RUNS = 5
|
|
1604
1439
|
DEFAULT_CONTEXT = 150_000
|
|
1605
1440
|
|
|
1606
|
-
# Claude Code caches for 1 hour.
|
|
1607
|
-
#
|
|
1608
|
-
# fresh session with the handoff note.
|
|
1609
|
-
CACHE_WINDOWS = { "codex" => 300 }.freeze
|
|
1441
|
+
# Claude Code caches for 1 hour. A harness with a shorter cache sets its
|
|
1442
|
+
# cache_window. A cold resume costs more than a fresh session with the handoff note.
|
|
1610
1443
|
DEFAULT_CACHE_WINDOW = 3300
|
|
1611
1444
|
|
|
1612
1445
|
def self.parse(argv, env = ENV)
|
|
1613
1446
|
config = Config.new(**DEFAULTS)
|
|
1614
1447
|
parser(config).parse!(argv)
|
|
1615
1448
|
config.role = argv.shift || abort("dispatcher: role is required (usage: dispatcher ROLE)")
|
|
1449
|
+
abort "dispatcher: --harness NAME or --command TEMPLATE is required" unless config.harness || config.command
|
|
1616
1450
|
validate(finish(config, env))
|
|
1617
1451
|
end
|
|
1618
1452
|
|
|
@@ -1650,7 +1484,7 @@ module Dispatcher
|
|
|
1650
1484
|
config.coord_dir = File.expand_path(env.fetch("COORD_DIR", ".maf/coordination"))
|
|
1651
1485
|
config.worker = env.fetch("COORD_WORKER", "#{config.role}-bot")
|
|
1652
1486
|
config.poll_tasks = false if LEADS.include?(config.role)
|
|
1653
|
-
|
|
1487
|
+
Harness.for(config).prepare(limits(config))
|
|
1654
1488
|
end
|
|
1655
1489
|
|
|
1656
1490
|
def self.limits(config)
|
|
@@ -1662,19 +1496,14 @@ module Dispatcher
|
|
|
1662
1496
|
# "team": { "limits": { "frontend-developer": { "max_context": 80000 } } }.
|
|
1663
1497
|
# `maf worker restart ID --max-context N` writes them. A command-line flag wins.
|
|
1664
1498
|
# NOTE: assets/dashboard reads the limits of each run from the run history.
|
|
1499
|
+
def self.cache_window(config) = Harness.for(config).cache_window || DEFAULT_CACHE_WINDOW
|
|
1500
|
+
|
|
1665
1501
|
def self.session_limits(config, saved)
|
|
1666
|
-
config.cache_window ||= saved["cache_window"] ||
|
|
1502
|
+
config.cache_window ||= saved["cache_window"] || cache_window(config)
|
|
1667
1503
|
config.max_session_runs ||= saved["max_session_runs"] || DEFAULT_SESSION_RUNS
|
|
1668
1504
|
config.max_context ||= saved["max_context"] || DEFAULT_CONTEXT
|
|
1669
1505
|
config
|
|
1670
1506
|
end
|
|
1671
|
-
|
|
1672
|
-
# --no-skill drops a skill. Without --skill, the generated Hermes skill of the role loads.
|
|
1673
|
-
def self.skill(config)
|
|
1674
|
-
config.skill = nil if config.no_skill
|
|
1675
|
-
config.skill ||= HermesSkill.name(config.role) if !config.no_skill && HermesSkill.installed?(config.role)
|
|
1676
|
-
config
|
|
1677
|
-
end
|
|
1678
1507
|
end
|
|
1679
1508
|
end
|
|
1680
1509
|
|