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/lib/maf/flow/models.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "open3"
|
|
4
|
-
|
|
5
3
|
module Maf
|
|
6
4
|
module Flow
|
|
7
5
|
# Models finds the installed harnesses and the models that each one offers.
|
|
@@ -9,52 +7,30 @@ module Maf
|
|
|
9
7
|
# harness does not know: a typo makes every dispatched run fail.
|
|
10
8
|
# A list is nil when maf cannot read it. Then every name counts as known.
|
|
11
9
|
module Models
|
|
12
|
-
# Claude Code has no command that lists models. The aliases follow the
|
|
13
|
-
# newest model of each family. A full ID is claude-<family>-<version>.
|
|
14
|
-
CLAUDE = %w[claude-opus-5-5 claude-sonnet-5-5 claude-haiku-5-5 claude-fable-5-1 opus sonnet haiku fable].freeze
|
|
15
|
-
CLAUDE_NAME = /\A(?:(?:opus|sonnet|haiku|fable)(?:\[1m\])?|opusplan|default|best|
|
|
16
|
-
(?:[\w.-]+\.)?(?:anthropic\.)?claude-(?:opus|sonnet|haiku|fable)-\d[\w.:@\[\]-]*)\z/x
|
|
17
|
-
CODEX_CACHE = File.join(Dir.home, ".codex", "models_cache.json")
|
|
18
|
-
|
|
19
10
|
# Without any harness on PATH, maf shows all of them: the user may install one later.
|
|
20
11
|
def self.installed
|
|
21
|
-
found =
|
|
22
|
-
found.empty? ?
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.on_path?(command)
|
|
26
|
-
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
|
|
12
|
+
found = Harness.all.select(&:installed?).map(&:name)
|
|
13
|
+
found.empty? ? Harness.names : found
|
|
27
14
|
end
|
|
28
15
|
|
|
16
|
+
# A harness can read its list from a CLI, so each list is read once.
|
|
29
17
|
def self.for(harness)
|
|
30
18
|
@lists ||= {}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@lists[harness] = { "claude" => -> { CLAUDE }, "codex" => -> { codex }, "opencode" => -> { opencode } }
|
|
34
|
-
.fetch(harness, -> {}).call
|
|
19
|
+
@lists.key?(harness) ? @lists[harness] : @lists[harness] = Harness[harness]&.models
|
|
35
20
|
end
|
|
36
21
|
|
|
37
|
-
def self.known?(harness, model)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Codex keeps the model list of the account in a cache file. Hidden models are internal.
|
|
44
|
-
def self.codex
|
|
45
|
-
models = JSON.parse(File.read(CODEX_CACHE)).fetch("models")
|
|
46
|
-
models.select { |m| m["visibility"] == "list" }.map { |m| m["slug"] }
|
|
47
|
-
rescue SystemCallError, JSON::ParserError, KeyError, NoMethodError
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
22
|
+
def self.known?(harness, model) = Harness[harness]&.known_model?(model, self.for(harness)) != false
|
|
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))
|
|
50
26
|
|
|
51
|
-
|
|
52
|
-
|
|
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)
|
|
53
32
|
|
|
54
|
-
|
|
55
|
-
status.success? && !out.strip.empty? ? out.split : nil
|
|
56
|
-
rescue SystemCallError
|
|
57
|
-
nil
|
|
33
|
+
levels.first if Harness[harness]&.effort_required?(model)
|
|
58
34
|
end
|
|
59
35
|
end
|
|
60
36
|
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,8 +23,8 @@ module Maf
|
|
|
22
23
|
].freeze
|
|
23
24
|
|
|
24
25
|
def initialize(argv)
|
|
25
|
-
@argv, @agents, @removed, @models = argv, [], [], {}
|
|
26
|
-
@
|
|
26
|
+
@argv, @agents, @removed, @models, @efforts = argv, [], [], {}, {}
|
|
27
|
+
@role_efforts = {}
|
|
27
28
|
@check = @force = @list_roles = false
|
|
28
29
|
@bootstrap = true
|
|
29
30
|
end
|
|
@@ -32,6 +33,9 @@ module Maf
|
|
|
32
33
|
def force? = @force
|
|
33
34
|
def bootstrap? = @bootstrap
|
|
34
35
|
|
|
36
|
+
# The settings that a harness reads for its role file folder.
|
|
37
|
+
def settings = { "hermes_dir" => @hermes_dir }.compact
|
|
38
|
+
|
|
35
39
|
def parse
|
|
36
40
|
parser.parse!(@argv)
|
|
37
41
|
self
|
|
@@ -43,11 +47,35 @@ module Maf
|
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
def model_for(agent)
|
|
46
|
-
agent[:model] || @models[agent[:role]] || agent[:saved_model] ||
|
|
50
|
+
agent[:model] || @models[agent[:role]] || agent[:saved_model] || Harness[agent[:harness]]&.default_model
|
|
51
|
+
end
|
|
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))
|
|
47
61
|
end
|
|
48
62
|
|
|
49
63
|
private
|
|
50
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
|
+
|
|
51
79
|
def parser
|
|
52
80
|
OptionParser.new do |o|
|
|
53
81
|
o.banner = "Usage: maf add|remove|update [HARNESS:ROLE ...] [options]"
|
|
@@ -26,26 +26,19 @@ module Maf
|
|
|
26
26
|
"#{intro(data)}\n\n#{duties_block(data)}\n\n#{loop_text}"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
# woken, so it blocks in `coord next --wait`.
|
|
34
|
-
# The dispatcher adds the report block rule to each dispatched prompt.
|
|
35
|
-
def no_task_line(harness)
|
|
36
|
-
return NO_TASK_AWAIT if harness == "codex"
|
|
37
|
-
|
|
38
|
-
WAKE_HARNESSES.include?(harness) ? NO_TASK_STOP : NO_TASK_WAIT
|
|
39
|
-
end
|
|
29
|
+
# A harness that wakes an idle session lets the agent stop instead of
|
|
30
|
+
# waiting: each return of a wait costs one model call over the whole
|
|
31
|
+
# context. The dispatcher adds the report block rule to each dispatched prompt.
|
|
32
|
+
def no_task_line(harness) = NO_TASK.fetch(harness.wait)
|
|
40
33
|
|
|
41
34
|
def architect_prompt(harness, data)
|
|
42
35
|
loop_text = project_manager? ? ARCHITECT_LOOP_PM : ARCHITECT_LOOP_DIRECT
|
|
43
36
|
"#{intro(data)} You do not implement code yourself.\n\n#{duties_block(data)}\n\n" \
|
|
44
|
-
"#{format(loop_text, roles: dispatch_roles_text)}#{
|
|
37
|
+
"#{format(loop_text, roles: dispatch_roles_text)}#{harness_rule(harness)}#{workflow_block}"
|
|
45
38
|
end
|
|
46
39
|
|
|
47
40
|
# The loop text ends with a newline, so the rule lands as the last rule line.
|
|
48
|
-
def
|
|
41
|
+
def harness_rule(harness) = harness.architect_rule ? "#{harness.architect_rule}\n" : ""
|
|
49
42
|
|
|
50
43
|
# The workflow goes into the orchestrator prompt only. Workers stay workflow-blind.
|
|
51
44
|
def workflow_block = @workflow ? "\n\n#{@workflow}" : ""
|
|
@@ -56,7 +49,7 @@ module Maf
|
|
|
56
49
|
|
|
57
50
|
def project_manager_prompt(harness, data)
|
|
58
51
|
"#{intro(data)} You do not plan tasks or implement code yourself.\n\n" \
|
|
59
|
-
"#{duties_block(data)}\n\n#{format(PM_LOOP, wait_instruction: PM_WAIT.fetch(harness
|
|
52
|
+
"#{duties_block(data)}\n\n#{format(PM_LOOP, wait_instruction: PM_WAIT.fetch(harness.wait))}"
|
|
60
53
|
end
|
|
61
54
|
|
|
62
55
|
def intro(data)
|
data/lib/maf/flow/prompt_text.rb
CHANGED
|
@@ -21,8 +21,7 @@ module Maf
|
|
|
21
21
|
- Do not use subagents to verify your work.
|
|
22
22
|
TEXT
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
WAKE_HARNESSES = %w[claude opencode].freeze
|
|
24
|
+
# The no-task rule of a worker, by the wait of the harness (Harness::RoleFiles#wait).
|
|
26
25
|
NO_TASK_STOP = "- If no task and no message is available, stop. The board watcher wakes you when work arrives.\n" \
|
|
27
26
|
" Do not wait in a loop: each return of a wait costs one model call."
|
|
28
27
|
NO_TASK_WAIT = <<~TEXT.strip
|
|
@@ -37,15 +36,16 @@ module Maf
|
|
|
37
36
|
The stop hook waits for work and wakes you. Do not wait in a loop.
|
|
38
37
|
TEXT
|
|
39
38
|
|
|
39
|
+
NO_TASK = { watch: NO_TASK_STOP, await: NO_TASK_AWAIT, poll: NO_TASK_WAIT }.freeze
|
|
40
|
+
|
|
40
41
|
# The project manager waits for reports. Each check in a loop sends the
|
|
41
42
|
# whole context again, so the wait must not call the model.
|
|
42
43
|
PM_WAIT = {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
watch: "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
|
|
45
|
+
await: "To wait for reports, run `coord await`. Tell the user that you wait. Then end your turn.\n" \
|
|
46
|
+
" The stop hook wakes you when a message arrives.",
|
|
47
|
+
poll: "Wait for reports with `coord inbox project-manager --wait --timeout 540`."
|
|
47
48
|
}.freeze
|
|
48
|
-
PM_WAIT_DEFAULT = "Wait for reports with `coord inbox project-manager --wait --timeout 540`."
|
|
49
49
|
|
|
50
50
|
# Every role except the project manager reads the code graph before work.
|
|
51
51
|
# A query output stays in the context for each later call, so the budget is small.
|
|
@@ -56,10 +56,6 @@ module Maf
|
|
|
56
56
|
Put one line in the report of that task or plan: "Graph: fresh", "Graph: stale", or "Graph: missing".
|
|
57
57
|
TEXT
|
|
58
58
|
|
|
59
|
-
# Only Claude Code has ScheduleWakeup. Other harnesses never see this rule.
|
|
60
|
-
CLAUDE_ARCHITECT_RULE = "- Never call ScheduleWakeup with `stop:false` and no `prompt`. " \
|
|
61
|
-
"The call fails. Poll worker status through the task tool instead."
|
|
62
|
-
|
|
63
59
|
# The coordination contract that every role shares. It lives in the role
|
|
64
60
|
# prompt, not in the project's AGENTS.md: only a session that maf starts
|
|
65
61
|
# needs it, and the project keeps no trace of the tool.
|
|
@@ -103,9 +99,12 @@ module Maf
|
|
|
103
99
|
- Before you continue a claimed task after a pause, run `coord show <id>`. If the status is not pending,
|
|
104
100
|
or the worker is not you, stop work on that task. `coord reap` releases the claims of stalled workers.
|
|
105
101
|
- Finish the whole task. Report done only when each acceptance criterion passes.
|
|
106
|
-
- If you cannot finish, do the parts you can
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
- If you cannot finish, do the parts you can and commit them on the task branch.
|
|
103
|
+
Then block the task: `coord block <id> --kind <kind> "<blocker and missing parts>"`.
|
|
104
|
+
Kinds: needs_input (a decision of the user), dependency (other work must land first),
|
|
105
|
+
capability (a missing tool, access, or model). coord tells the role that can remove the blocker.
|
|
106
|
+
A blocked task leaves `coord next`, so no other worker hits the blocker.
|
|
107
|
+
Stop. Do not retry a failing approach.
|
|
109
108
|
%{no_task_instruction}
|
|
110
109
|
- Record durable knowledge in the shared vault or #{DECISIONS}.
|
|
111
110
|
- Never write ad-hoc verification scripts. The test suite is the verification.
|
data/lib/maf/flow/report.rb
CHANGED
|
@@ -12,20 +12,21 @@ module Maf
|
|
|
12
12
|
|
|
13
13
|
def print(results)
|
|
14
14
|
puts "", "Generated role files:", results.map { |result| generated_line(result) }
|
|
15
|
-
%i[print_missing_models print_unknown_models print_unembeddable
|
|
16
|
-
|
|
15
|
+
%i[print_missing_models print_unknown_models print_unembeddable].each { |name| send(name, results) }
|
|
16
|
+
EffortReport.print(results)
|
|
17
|
+
print_sessions(results)
|
|
17
18
|
print_hermes_reminder
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
22
|
-
# The
|
|
23
|
+
# The Hermes steps print at install time, far above the last lines the user
|
|
23
24
|
# reads. Repeat the state here so the flow is not started with a dead hook.
|
|
24
25
|
def print_hermes_reminder
|
|
25
26
|
return unless @hermes_pending
|
|
26
27
|
|
|
27
|
-
puts "The Hermes
|
|
28
|
-
"Then run `maf update` to confirm
|
|
28
|
+
puts "The Hermes setup is not done yet. Finish the steps above.",
|
|
29
|
+
"Then run `maf update` to confirm that Hermes is ready."
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def generated_line(result)
|
|
@@ -74,16 +75,14 @@ module Maf
|
|
|
74
75
|
def pm?(result) = result[:agent][:role] == "project-manager"
|
|
75
76
|
|
|
76
77
|
def print_unembeddable(results)
|
|
77
|
-
list = results.select { |r| r[:model] &&
|
|
78
|
+
list = results.select { |r| r[:model] && model_command(r) }
|
|
78
79
|
return if list.empty?
|
|
79
80
|
|
|
80
81
|
puts "", "These harnesses cannot embed a model in the role file. Set it in the harness:",
|
|
81
82
|
list.map { |r| " #{r[:agent][:harness]}:#{r[:agent][:role]} -> #{model_command(r)}" }
|
|
82
83
|
end
|
|
83
84
|
|
|
84
|
-
def model_command(result)
|
|
85
|
-
result[:agent][:harness] == "codex" ? "codex -m #{result[:model]}" : "hermes model"
|
|
86
|
-
end
|
|
85
|
+
def model_command(result) = Harness.fetch(result[:agent][:harness]).model_hint(result[:model])
|
|
87
86
|
end
|
|
88
87
|
end
|
|
89
88
|
end
|
|
@@ -6,7 +6,7 @@ module Maf
|
|
|
6
6
|
# role in .maf/roles.yml adds a role or replaces a built-in role.
|
|
7
7
|
class RoleCatalog
|
|
8
8
|
FILE = ".maf/roles.yml"
|
|
9
|
-
DEFAULTS = { "model_hint" => "", "can_edit" => true }.freeze
|
|
9
|
+
DEFAULTS = { "model_hint" => "", "can_edit" => true, "effort" => nil }.freeze
|
|
10
10
|
|
|
11
11
|
def initialize(project)
|
|
12
12
|
@project = project
|
data/lib/maf/flow/role_files.rb
CHANGED
|
@@ -19,30 +19,15 @@ module Maf
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def generate_agent(agent)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
dest =
|
|
25
|
-
content = render(agent[:
|
|
26
|
-
{ agent: agent, dest: dest, status: write(dest, content),
|
|
22
|
+
harness = Harness.fetch(agent[:harness])
|
|
23
|
+
choice = { model: @options.model_for(agent), effort: @options.effort_for(agent) }
|
|
24
|
+
dest = harness.role_destination(@options.project, agent[:role], @options.settings)
|
|
25
|
+
content = render(harness, agent[:role], @roles.fetch(agent[:role]), choice)
|
|
26
|
+
{ agent: agent, dest: dest, status: write(dest, content), **choice }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# don't overwrite each other's skill.
|
|
32
|
-
def destination(harness, role)
|
|
33
|
-
return hermes_path(role) if harness == "hermes"
|
|
34
|
-
|
|
35
|
-
File.join(@options.project, Flow.role_path(harness, role)) if %w[opencode claude codex].include?(harness)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def hermes_path(role)
|
|
39
|
-
File.join(@options.hermes_dir, "#{File.basename(@options.project)}-#{role}", "SKILL.md")
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def render(harness, role, data, model)
|
|
43
|
-
template = File.read(File.join(TEMPLATES, "#{harness}.md.erb"))
|
|
44
|
-
values = { prompt: @prompts.build(harness, role, data), model: model }
|
|
45
|
-
ERB.new(template, trim_mode: "-").result_with_hash(**template_values(role, data), **values)
|
|
29
|
+
def render(harness, role, data, choice)
|
|
30
|
+
harness.render_role(**template_values(role, data), prompt: @prompts.build(harness, role, data), **choice)
|
|
46
31
|
end
|
|
47
32
|
|
|
48
33
|
def template_values(role, data)
|
data/lib/maf/flow/roster.rb
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Maf
|
|
4
4
|
module Flow
|
|
5
5
|
# Roster merges the agents saved in .maf/config.json with the --agent and
|
|
6
|
-
# --remove specs of this run. A saved model ranks below --model
|
|
6
|
+
# --remove specs of this run. A saved model ranks below --model, a saved
|
|
7
|
+
# effort below --effort.
|
|
7
8
|
class Roster
|
|
8
9
|
def initialize(project)
|
|
9
10
|
path = File.join(project, ".maf/config.json")
|
|
@@ -11,7 +12,7 @@ module Maf
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def merge(added, removed)
|
|
14
|
-
kept = saved.map { |a| added.find { |b| same?(a, b) }&.merge(saved_model
|
|
15
|
+
kept = saved.map { |a| added.find { |b| same?(a, b) }&.merge(a.slice(:saved_model, :saved_effort)) || a }
|
|
15
16
|
agents = kept + added.reject { |b| kept.any? { |a| same?(a, b) } }
|
|
16
17
|
agents.reject { |a| removed.any? { |b| same?(a, b) } }
|
|
17
18
|
end
|
|
@@ -19,7 +20,9 @@ module Maf
|
|
|
19
20
|
private
|
|
20
21
|
|
|
21
22
|
def saved
|
|
22
|
-
@saved.map
|
|
23
|
+
@saved.map do |a|
|
|
24
|
+
{ harness: a["harness"], role: a["role"], saved_model: a["model"], saved_effort: a["effort"] }
|
|
25
|
+
end
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def same?(one, other) = one[:harness] == other[:harness] && one[:role] == other[:role]
|
data/lib/maf/flow/validator.rb
CHANGED
|
@@ -28,11 +28,11 @@ module Maf
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
30
|
def check(agent)
|
|
31
|
-
abort "flow: unknown harness '#{agent[:harness]}' (use #{
|
|
31
|
+
abort "flow: unknown harness '#{agent[:harness]}' (use #{Harness.names.join(", ")})" unless known?(agent)
|
|
32
32
|
abort "flow: unknown role '#{agent[:role]}'" unless @roles.key?(agent[:role])
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def known?(agent) =
|
|
35
|
+
def known?(agent) = Harness.known?(agent[:harness])
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
end
|
data/lib/maf/flow.rb
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
#
|
|
5
5
|
# maf add, maf remove, maf update, and maf roles run Flow::Generator.
|
|
6
6
|
# Options: --project DIR [--agent HARNESS:ROLE ...] [--remove HARNESS:ROLE ...]
|
|
7
|
-
# [--model ROLE=MODEL] [--hermes-dir DIR] [--check] [--force] [--no-bootstrap]
|
|
7
|
+
# [--model ROLE=MODEL] [--effort ROLE=LEVEL] [--hermes-dir DIR] [--check] [--force] [--no-bootstrap]
|
|
8
8
|
# [--list-roles]
|
|
9
9
|
#
|
|
10
|
-
# HARNESS is
|
|
10
|
+
# HARNESS is a harness of the registry: one file in lib/maf/shared/harnesses/.
|
|
11
11
|
# A re-run keeps the agents in .maf/config.json. --agent adds an agent.
|
|
12
12
|
# --remove drops an agent. Without --agent, a re-run regenerates the current agents.
|
|
13
13
|
# Idempotent: identical files are skipped, changed files are updated, and
|
|
@@ -20,6 +20,7 @@ require "optparse"
|
|
|
20
20
|
require "rbconfig"
|
|
21
21
|
require "time"
|
|
22
22
|
require_relative "shared/roles"
|
|
23
|
+
require_relative "shared/harness"
|
|
23
24
|
|
|
24
25
|
abort "flow: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
|
|
25
26
|
|
|
@@ -28,32 +29,15 @@ module Maf
|
|
|
28
29
|
ROOT = File.expand_path("../..", __dir__)
|
|
29
30
|
TEMPLATES = File.join(ROOT, "templates")
|
|
30
31
|
ASSETS = File.join(ROOT, "assets")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# Model a harness gets when neither --agent nor --model names one.
|
|
35
|
-
# Other harnesses have no safe default, so their CLI picks the model.
|
|
36
|
-
DEFAULT_MODELS = { "claude" => "claude-opus-5-5" }.freeze
|
|
32
|
+
# The registry of the harnesses. Each harness is one file in lib/maf/shared/harnesses/.
|
|
33
|
+
Harness = Maf::Shared::Harness
|
|
37
34
|
|
|
38
35
|
# Roles that dispatch or coordinate instead of implementing. They are never
|
|
39
36
|
# advertised as dispatch targets in the architect prompt.
|
|
40
37
|
LEADS = Maf::Shared::Roles::LEADS
|
|
41
38
|
DISPATCH_EXCLUDE = LEADS
|
|
42
39
|
|
|
43
|
-
READ_ONLY_TOOLSETS = Maf::Shared::Roles::READ_ONLY_TOOLSETS
|
|
44
|
-
|
|
45
40
|
MAF_DIR = ".maf"
|
|
46
|
-
|
|
47
|
-
# The folder where a harness looks for role files. It is a symlink into
|
|
48
|
-
# .maf/agents/<harness>/, where the flow keeps the role files.
|
|
49
|
-
HARNESS_DIRS = { "opencode" => ".opencode/agents", "claude" => ".claude/agents",
|
|
50
|
-
"codex" => ".codex/prompts" }.freeze
|
|
51
|
-
|
|
52
|
-
# The role file path inside the project. Hermes keeps role files outside
|
|
53
|
-
# the project, so it has no path here.
|
|
54
|
-
def self.role_path(harness, role)
|
|
55
|
-
HARNESS_DIRS.key?(harness) ? File.join(MAF_DIR, "agents", harness, "#{role}.md") : nil
|
|
56
|
-
end
|
|
57
41
|
end
|
|
58
42
|
end
|
|
59
43
|
|
|
@@ -68,6 +52,7 @@ require_relative "flow/options"
|
|
|
68
52
|
require_relative "flow/prompt_builder"
|
|
69
53
|
require_relative "flow/hook_files"
|
|
70
54
|
require_relative "flow/hermes_hook_setup"
|
|
55
|
+
require_relative "flow/hermes_env_setup"
|
|
71
56
|
require_relative "flow/codex_hooks"
|
|
72
57
|
require_relative "flow/legacy_codex_hook"
|
|
73
58
|
require_relative "flow/hook_installer"
|
|
@@ -77,6 +62,7 @@ require_relative "flow/mcp_installer"
|
|
|
77
62
|
require_relative "flow/role_files"
|
|
78
63
|
require_relative "flow/manifest"
|
|
79
64
|
require_relative "flow/models"
|
|
65
|
+
require_relative "flow/effort_report"
|
|
80
66
|
require_relative "flow/report"
|
|
81
67
|
require_relative "flow/validator"
|
|
82
68
|
require_relative "flow/harness_linker"
|
data/lib/maf/menu.rb
CHANGED
|
@@ -21,14 +21,14 @@ module Maf
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# Only the installed harnesses are choices. Each role gets its own model.
|
|
24
|
+
# Only the installed harnesses are choices. Each role gets its own model and effort.
|
|
25
25
|
def add
|
|
26
26
|
harness = @prompt.choose("Harness", Flow::Models.installed) or return
|
|
27
27
|
roles = @prompt.choose_many("Roles", Maf.role_names)
|
|
28
28
|
return if roles.empty?
|
|
29
29
|
|
|
30
30
|
models = show_models(harness)
|
|
31
|
-
Maf.flow(*roles.flat_map { |role|
|
|
31
|
+
Maf.flow(*roles.flat_map { |role| agent_args(harness, role, models) })
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
private
|
|
@@ -56,32 +56,66 @@ module Maf
|
|
|
56
56
|
@prompt.say("error: #{e.message}")
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def agent_args(harness, role, models)
|
|
60
60
|
model = ask_model(harness, role, models).to_s
|
|
61
|
-
|
|
61
|
+
effort = ask_effort(harness, role, model.empty? ? nil : model).to_s
|
|
62
|
+
flags = effort.empty? ? [] : ["--effort", "#{role}=#{effort}"]
|
|
63
|
+
["--agent", [harness, role, model].reject(&:empty?).join(":"), *flags]
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# every dispatched run fail.
|
|
66
|
+
# A model name that the harness does not list needs a confirmation: a typo
|
|
67
|
+
# makes every dispatched run fail.
|
|
67
68
|
def ask_model(harness, role, models)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return models[number - 1] if number&.between?(1, models.size)
|
|
71
|
-
return answer if answer.empty? || Flow::Models.known?(harness, answer)
|
|
72
|
-
|
|
73
|
-
@prompt.confirm?("#{harness} does not list #{answer}. Use it anyway?") ? answer : ask_model(harness, role, models)
|
|
69
|
+
question = "Model for #{role} (number or name, Enter = #{default_label(harness)}):"
|
|
70
|
+
ask_listed(harness, question, models) { |name| Flow::Models.known?(harness, name) }
|
|
74
71
|
end
|
|
75
72
|
|
|
76
|
-
def default_label(harness) = Flow::
|
|
73
|
+
def default_label(harness) = Flow::Models.default(harness) || "the harness default"
|
|
77
74
|
|
|
78
75
|
def show_models(harness)
|
|
79
76
|
models = Flow::Models.for(harness).to_a
|
|
80
77
|
@prompt.say(models.empty? ? "#{harness} lists no models. Type a name." : "Models of #{harness}:")
|
|
81
|
-
models.each_with_index { |model, i| @prompt.say(" #{i + 1}) #{model}") }
|
|
78
|
+
models.each_with_index { |model, i| @prompt.say(" #{i + 1}) #{model}#{levels_label(harness, model)}") }
|
|
82
79
|
models
|
|
83
80
|
end
|
|
84
81
|
|
|
82
|
+
def levels_label(harness, model)
|
|
83
|
+
levels = Flow::Models.efforts(harness, model).to_a
|
|
84
|
+
levels.empty? ? "" : " (effort: #{levels.join(", ")})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# An empty list: the model takes no level, so maf does not ask.
|
|
88
|
+
# nil: the harness lists no levels, so every level counts as known.
|
|
89
|
+
def ask_effort(harness, role, model)
|
|
90
|
+
levels = Flow::Models.efforts(harness, model)
|
|
91
|
+
return if levels == []
|
|
92
|
+
|
|
93
|
+
levels.to_a.each_with_index { |level, i| @prompt.say(" #{i + 1}) #{level}") }
|
|
94
|
+
question = "Effort for #{role} (number or name, Enter = #{effort_label(harness, role, model)}):"
|
|
95
|
+
ask_listed(harness, question, levels.to_a) { |name| levels.nil? || levels.include?(name) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def effort_label(harness, role, model)
|
|
99
|
+
Flow::Models.default_effort(harness, Maf.role_effort(role), model) || "the harness default"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The answer is a number of the list, a name, or Enter for the default.
|
|
103
|
+
def ask_listed(harness, question, list, &known)
|
|
104
|
+
answer = @prompt.ask(question).to_s
|
|
105
|
+
picked = pick(answer, list, &known)
|
|
106
|
+
return picked if picked
|
|
107
|
+
return answer if @prompt.confirm?("#{harness} does not list #{answer}. Use it anyway?")
|
|
108
|
+
|
|
109
|
+
ask_listed(harness, question, list, &known)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def pick(answer, list)
|
|
113
|
+
number = Integer(answer, exception: false)
|
|
114
|
+
return list[number - 1] if number&.between?(1, list.size)
|
|
115
|
+
|
|
116
|
+
answer if answer.empty? || yield(answer)
|
|
117
|
+
end
|
|
118
|
+
|
|
85
119
|
def remove
|
|
86
120
|
spec = @prompt.choose("Agent", Maf.agent_specs) or return
|
|
87
121
|
Maf.flow("--remove", spec) if @prompt.confirm?("Remove #{spec}?")
|
|
@@ -11,7 +11,9 @@ module Maf
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def steps
|
|
14
|
-
|
|
14
|
+
Maf::Shared::Harness.all.select(&:role_dir).flat_map do |harness|
|
|
15
|
+
files(harness.role_dir).map { |file| step(harness.name, harness.role_dir, file) }
|
|
16
|
+
end
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
private
|