maf 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +411 -0
- data/assets/agents-contract.md +80 -0
- data/assets/analyst +240 -0
- data/assets/coord +2936 -0
- data/assets/dashboard +553 -0
- data/assets/dashboard.html +341 -0
- data/assets/dispatcher +1687 -0
- data/assets/doc-graph-refresh +286 -0
- data/assets/env.sh +6 -0
- data/assets/git-hooks/post-commit +7 -0
- data/assets/git-hooks/post-merge +7 -0
- data/assets/git-hooks/pre-commit +32 -0
- data/assets/harness-hooks/board-watch-opencode.js +87 -0
- data/assets/harness-hooks/board-watch.rb +286 -0
- data/assets/harness-hooks/context-watch.rb +268 -0
- data/assets/harness-hooks/next-task-hermes.sh +48 -0
- data/assets/harness-hooks/next-task.rb +97 -0
- data/assets/harness-hooks/session-guard.rb +128 -0
- data/assets/taskrc.append +11 -0
- data/assets/vault +224 -0
- data/assets/worktree-env.example.rb +26 -0
- data/exe/maf +14 -0
- data/install.md +326 -0
- data/lib/maf/bootstrap/claude_settings.rb +55 -0
- data/lib/maf/bootstrap/dependencies.rb +37 -0
- data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
- data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
- data/lib/maf/bootstrap/graph_home.rb +62 -0
- data/lib/maf/bootstrap/hook_merger.rb +53 -0
- data/lib/maf/bootstrap/installer.rb +66 -0
- data/lib/maf/bootstrap/layout_planner.rb +18 -0
- data/lib/maf/bootstrap/marked_block.rb +44 -0
- data/lib/maf/bootstrap/memory_branch.rb +77 -0
- data/lib/maf/bootstrap/options.rb +34 -0
- data/lib/maf/bootstrap/project.rb +77 -0
- data/lib/maf/bootstrap/script_planner.rb +81 -0
- data/lib/maf/bootstrap/text_planner.rb +42 -0
- data/lib/maf/bootstrap/vault_starter.rb +41 -0
- data/lib/maf/bootstrap/writer.rb +69 -0
- data/lib/maf/bootstrap.rb +162 -0
- data/lib/maf/budget.rb +59 -0
- data/lib/maf/cli.rb +135 -0
- data/lib/maf/env_exclude.rb +23 -0
- data/lib/maf/flow/agent_links.rb +79 -0
- data/lib/maf/flow/bootstrapper.rb +36 -0
- data/lib/maf/flow/codex_hooks.rb +50 -0
- data/lib/maf/flow/generator.rb +63 -0
- data/lib/maf/flow/harness_linker.rb +37 -0
- data/lib/maf/flow/hermes_hook.rb +48 -0
- data/lib/maf/flow/hermes_hook_setup.rb +69 -0
- data/lib/maf/flow/hook_files.rb +16 -0
- data/lib/maf/flow/hook_installer.rb +33 -0
- data/lib/maf/flow/legacy_codex_hook.rb +71 -0
- data/lib/maf/flow/manifest.rb +51 -0
- data/lib/maf/flow/mcp_config.rb +72 -0
- data/lib/maf/flow/mcp_installer.rb +45 -0
- data/lib/maf/flow/models.rb +61 -0
- data/lib/maf/flow/options.rb +65 -0
- data/lib/maf/flow/prompt_builder.rb +85 -0
- data/lib/maf/flow/prompt_text.rb +263 -0
- data/lib/maf/flow/report.rb +89 -0
- data/lib/maf/flow/role_catalog.rb +40 -0
- data/lib/maf/flow/role_files.rb +72 -0
- data/lib/maf/flow/role_stub.rb +38 -0
- data/lib/maf/flow/roster.rb +28 -0
- data/lib/maf/flow/validator.rb +38 -0
- data/lib/maf/flow/workflow.rb +28 -0
- data/lib/maf/flow.rb +84 -0
- data/lib/maf/local_exclude.rb +53 -0
- data/lib/maf/menu.rb +101 -0
- data/lib/maf/migrate/moves.rb +44 -0
- data/lib/maf/migrate/rewrites.rb +53 -0
- data/lib/maf/migrate/role_files.rb +35 -0
- data/lib/maf/migrate/runner.rb +66 -0
- data/lib/maf/migrate/worktrees.rb +65 -0
- data/lib/maf/migrate.rb +62 -0
- data/lib/maf/prompt.rb +40 -0
- data/lib/maf/retire.rb +116 -0
- data/lib/maf/role_limits.rb +49 -0
- data/lib/maf/setup_agent/args.rb +57 -0
- data/lib/maf/setup_agent/dispatch.rb +44 -0
- data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
- data/lib/maf/setup_agent/hermes_skill.rb +26 -0
- data/lib/maf/setup_agent/launcher.rb +85 -0
- data/lib/maf/setup_agent/manifest.rb +35 -0
- data/lib/maf/setup_agent/project.rb +9 -0
- data/lib/maf/setup_agent/role_file.rb +30 -0
- data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
- data/lib/maf/setup_agent/worktree.rb +50 -0
- data/lib/maf/setup_agent.rb +111 -0
- data/lib/maf/shared/git_exclude.rb +33 -0
- data/lib/maf/shared/git_identity.rb +41 -0
- data/lib/maf/shared/peak_rate.rb +20 -0
- data/lib/maf/shared/processes.rb +31 -0
- data/lib/maf/shared/project.rb +34 -0
- data/lib/maf/shared/roles.rb +19 -0
- data/lib/maf/team.rb +114 -0
- data/lib/maf/team_command.rb +73 -0
- data/lib/maf/uninstall/claude_settings.rb +40 -0
- data/lib/maf/uninstall/codex_hooks.rb +18 -0
- data/lib/maf/uninstall/commit_guard.rb +16 -0
- data/lib/maf/uninstall/coordination.rb +15 -0
- data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
- data/lib/maf/uninstall/git.rb +13 -0
- data/lib/maf/uninstall/local_files.rb +33 -0
- data/lib/maf/uninstall/manifest.rb +29 -0
- data/lib/maf/uninstall/marked_files.rb +37 -0
- data/lib/maf/uninstall/mcp_entries.rb +43 -0
- data/lib/maf/uninstall/notes.rb +31 -0
- data/lib/maf/uninstall/owned.rb +12 -0
- data/lib/maf/uninstall/role_files.rb +51 -0
- data/lib/maf/uninstall/runner.rb +67 -0
- data/lib/maf/uninstall/scripts.rb +35 -0
- data/lib/maf/uninstall/vault_watcher.rb +21 -0
- data/lib/maf/uninstall/worktrees.rb +30 -0
- data/lib/maf/uninstall.rb +59 -0
- data/lib/maf/untrack.rb +90 -0
- data/lib/maf/version.rb +5 -0
- data/lib/maf/worker_archive.rb +63 -0
- data/lib/maf/worker_control.rb +137 -0
- data/lib/maf/workers.rb +37 -0
- data/lib/maf.rb +5 -0
- data/templates/claude.md.erb +16 -0
- data/templates/codex.md.erb +7 -0
- data/templates/hermes.md.erb +12 -0
- data/templates/opencode.md.erb +24 -0
- data/templates/role-stub.yml.erb +15 -0
- data/templates/roles.yml +289 -0
- data/templates/workflows/panel.md +20 -0
- data/templates/workflows/plan-review.md +9 -0
- data/templates/workflows/simple.md +4 -0
- data/templates/workflows/tdd.md +8 -0
- metadata +193 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# HermesHook reports the state of the hook that picks up tasks when a Hermes
|
|
6
|
+
# session ends. Hermes keeps hooks in the global config.yaml and asks for a
|
|
7
|
+
# one-time consent, so the flow prints commands instead of editing the file.
|
|
8
|
+
# Every method takes a path, so a test can use fixtures.
|
|
9
|
+
module HermesHook
|
|
10
|
+
EVENT = "on_session_end"
|
|
11
|
+
SCRIPT_NAME = "next-task.sh"
|
|
12
|
+
TIMEOUT = 30
|
|
13
|
+
# Hermes rounds the recorded approval time to microseconds, so compare with
|
|
14
|
+
# a small tolerance. A rewritten hook moves the mtime far past it.
|
|
15
|
+
MTIME_TOLERANCE = 2
|
|
16
|
+
|
|
17
|
+
def self.declared?(config_yaml, script_path)
|
|
18
|
+
return false unless File.exist?(config_yaml)
|
|
19
|
+
|
|
20
|
+
content = File.read(config_yaml)
|
|
21
|
+
content.include?(script_path) || content.include?(SCRIPT_NAME)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.approved?(allowlist, script_path, mtime)
|
|
25
|
+
entry = approvals(allowlist).find { |item| item["command"] == script_path && item["event"] == EVENT }
|
|
26
|
+
entry ? (mtime - Time.iso8601(entry["script_mtime_at_approval"].to_s)).abs <= MTIME_TOLERANCE : false
|
|
27
|
+
rescue ArgumentError, TypeError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.approvals(allowlist)
|
|
32
|
+
return [] unless File.exist?(allowlist)
|
|
33
|
+
|
|
34
|
+
JSON.parse(File.read(allowlist)).fetch("approvals", [])
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.config_command(script_path)
|
|
40
|
+
%(hermes config set hooks.#{EVENT} '[{"command":"#{script_path}","timeout":#{TIMEOUT}}]')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.approve_command = "hermes chat --oneshot --accept-hooks -q ok"
|
|
44
|
+
|
|
45
|
+
def self.check_command = "hermes hooks doctor"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# HermesHookSetup installs the Hermes hook script. It prints the steps that
|
|
6
|
+
# the user must do. The flow never edits the Hermes config: the file is
|
|
7
|
+
# comment-rich, and Hermes guards it as security-sensitive. It prints only
|
|
8
|
+
# the step that is still missing.
|
|
9
|
+
class HermesHookSetup
|
|
10
|
+
def initialize
|
|
11
|
+
@home = File.join(Dir.home, ".hermes")
|
|
12
|
+
@script = File.join(@home, "agent-hooks", HermesHook::SCRIPT_NAME)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns true when the user has steps left.
|
|
16
|
+
def install
|
|
17
|
+
install_files
|
|
18
|
+
steps = self.steps(File.mtime(@script))
|
|
19
|
+
return puts(" hook ready: #{@script}") || false if steps.empty?
|
|
20
|
+
|
|
21
|
+
print_steps(steps) || true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def install_files
|
|
27
|
+
HookFiles.copy(File.join(ASSETS, "harness-hooks", "session-guard.rb"),
|
|
28
|
+
File.join(File.dirname(@script), "session-guard.rb"))
|
|
29
|
+
HookFiles.copy(File.join(ASSETS, "harness-hooks", "next-task-hermes.sh"), @script)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def print_steps(steps)
|
|
33
|
+
puts "", " Hermes hook: #{@script}", " The hook does not run yet. Do these steps one time.", ""
|
|
34
|
+
steps.each_with_index { |step, index| puts format_step(step, index) }
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_step(step, index)
|
|
39
|
+
lines = [" #{index + 1}. #{step[:title]}", " #{step[:command]}"]
|
|
40
|
+
lines << " #{step[:note]}" if step[:note]
|
|
41
|
+
"#{lines.join("\n")}\n"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def steps(mtime)
|
|
45
|
+
steps = [declare_step, approve_step(mtime)].reject { |step| step[:done] }
|
|
46
|
+
steps << check_step unless steps.empty?
|
|
47
|
+
steps
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def declare_step
|
|
51
|
+
{ done: HermesHook.declared?(File.join(@home, "config.yaml"), @script),
|
|
52
|
+
title: "Declare the hook in the Hermes config:",
|
|
53
|
+
command: HermesHook.config_command(@script),
|
|
54
|
+
note: "Keep your other on_session_end entries. Add this entry to that list." }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def approve_step(mtime)
|
|
58
|
+
{ done: HermesHook.approved?(File.join(@home, "shell-hooks-allowlist.json"), @script, mtime),
|
|
59
|
+
title: "Approve the hook one time:",
|
|
60
|
+
command: HermesHook.approve_command,
|
|
61
|
+
note: "Hermes stores the consent for this version of the script." }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def check_step
|
|
65
|
+
{ done: false, title: "Check the hook:", command: HermesHook.check_command, note: nil }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# HookFiles copies a hook script from the assets and makes it executable.
|
|
6
|
+
module HookFiles
|
|
7
|
+
def self.copy(src, dest)
|
|
8
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
9
|
+
return if File.exist?(dest) && File.read(dest) == File.read(src)
|
|
10
|
+
|
|
11
|
+
FileUtils.install(src, dest, mode: 0o755)
|
|
12
|
+
puts " hook install: #{dest}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# HookInstaller installs the harness hooks that pick up tasks when a
|
|
6
|
+
# session ends. Claude Code and opencode get theirs from bootstrap.
|
|
7
|
+
class HookInstaller
|
|
8
|
+
def initialize(agents, project)
|
|
9
|
+
@harnesses = agents.map { |a| a[:harness] }.uniq
|
|
10
|
+
@project = project
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns true when the Hermes hook needs steps from the user.
|
|
14
|
+
def install
|
|
15
|
+
install_codex if @harnesses.include?("codex")
|
|
16
|
+
@harnesses.include?("hermes") && HermesHookSetup.new.install
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def install_codex
|
|
22
|
+
%w[next-task.rb session-guard.rb].each { |name| copy_hook(name) }
|
|
23
|
+
CodexHooks.new(@project).install
|
|
24
|
+
LegacyCodexHook.new.remove
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def copy_hook(name)
|
|
28
|
+
HookFiles.copy(File.join(ASSETS, "harness-hooks", name),
|
|
29
|
+
File.join(@project, ".maf", "coordination", "harness-hooks", name))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Maf
|
|
6
|
+
module Flow
|
|
7
|
+
# Remove only the global hook that MAF installed.
|
|
8
|
+
class LegacyCodexHook
|
|
9
|
+
DISABLED = <<~RUBY
|
|
10
|
+
#!/usr/bin/env ruby
|
|
11
|
+
# next-task.rb - Stop hook for Claude Code and Codex.
|
|
12
|
+
# The legacy global MAF hook is disabled. Use project hooks with maf start.
|
|
13
|
+
exit 0
|
|
14
|
+
RUBY
|
|
15
|
+
|
|
16
|
+
def initialize(home = Dir.home)
|
|
17
|
+
@script = File.join(home, ".codex", "hooks", "next-task.rb")
|
|
18
|
+
@path = File.join(home, ".codex", "hooks.json")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def remove
|
|
22
|
+
return unless owned?
|
|
23
|
+
|
|
24
|
+
disable
|
|
25
|
+
clean_config if File.exist?(@path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clean_config
|
|
29
|
+
clean(JSON.parse(File.read(@path)))
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
warn "flow: invalid JSON in #{@path}; the legacy hook is disabled but its registration remains"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def disable
|
|
37
|
+
File.write(@script, DISABLED) unless File.read(@script) == DISABLED
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def owned?
|
|
41
|
+
File.file?(@script) && File.read(@script).include?("next-task.rb - Stop hook for Claude Code and Codex.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clean(data)
|
|
45
|
+
updated = stripped(data)
|
|
46
|
+
return if updated == data
|
|
47
|
+
|
|
48
|
+
File.write(@path, JSON.pretty_generate(updated))
|
|
49
|
+
puts " hook remove: #{@path} (legacy global MAF hook)"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stripped(data)
|
|
53
|
+
hooks = data.fetch("hooks", {}).transform_values { |entries| strip_entries(entries) }.reject { |_, v| v.empty? }
|
|
54
|
+
hooks.empty? ? data.except("hooks") : data.merge("hooks" => hooks)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def strip_entries(entries)
|
|
58
|
+
entries.map { |entry| entry.merge("hooks" => entry.fetch("hooks", []).reject { |hook| ours?(hook) }) }
|
|
59
|
+
.reject { |entry| entry["hooks"].empty? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ours?(hook)
|
|
63
|
+
command = Shellwords.split(hook["command"].to_s)
|
|
64
|
+
command.size == 2 && File.basename(command.first) == "ruby" &&
|
|
65
|
+
[@script, "~/.codex/hooks/next-task.rb"].include?(command.last)
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# Manifest writes .maf/config.json, the record of the agents of a project.
|
|
6
|
+
class Manifest
|
|
7
|
+
def initialize(options, roles)
|
|
8
|
+
@options = options
|
|
9
|
+
@roles = roles
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def write
|
|
13
|
+
return if @options.check?
|
|
14
|
+
|
|
15
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
16
|
+
File.write(path, JSON.pretty_generate(data))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def path
|
|
22
|
+
File.join(@options.project, ".maf/config.json")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def data
|
|
26
|
+
data = kept_keys.merge("generated_at" => Time.now.utc.iso8601, "agents" => @options.agents.map { |a| entry(a) })
|
|
27
|
+
# Only record hermes_dir when it differs from the default: the default is
|
|
28
|
+
# an absolute home path that would leak into a committed manifest.
|
|
29
|
+
data["hermes_dir"] = @options.hermes_dir unless @options.hermes_dir == DEFAULT_HERMES_DIR
|
|
30
|
+
data
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# can_edit lets maf start, the dispatcher, and the git commit guard limit
|
|
34
|
+
# a role without the role definitions.
|
|
35
|
+
def entry(agent)
|
|
36
|
+
{ harness: agent[:harness], role: agent[:role], model: @options.model_for(agent),
|
|
37
|
+
can_edit: @roles.fetch(agent[:role]).fetch("can_edit") }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Other tools own other keys (maf team: "team"; users: "base_branch").
|
|
41
|
+
# A re-run must keep them.
|
|
42
|
+
def kept_keys
|
|
43
|
+
return {} unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
JSON.parse(File.read(path)).except("generated_at", "agents", "hermes_dir")
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# McpConfig writes the graphify MCP server into a config file of the flow,
|
|
6
|
+
# in .maf/mcp/. The project's .mcp.json and opencode.json stay as they are.
|
|
7
|
+
# `maf start` and the dispatcher pass the file: Claude Code reads it with
|
|
8
|
+
# --mcp-config, opencode with the OPENCODE_CONFIG variable.
|
|
9
|
+
# The server runs `vault mcp`. A foreign entry with the same name stays.
|
|
10
|
+
# Codex and Hermes keep MCP servers in a global user file. maf does not
|
|
11
|
+
# edit that file. It prints the command that adds the server instead.
|
|
12
|
+
class McpConfig
|
|
13
|
+
NAME = "graphify"
|
|
14
|
+
ARGS = %w[ruby .maf/bin/vault mcp].freeze
|
|
15
|
+
SCHEMA = "https://opencode.ai/config.json"
|
|
16
|
+
TARGETS = {
|
|
17
|
+
"claude" => { file: ".maf/mcp/claude.json", key: "mcpServers",
|
|
18
|
+
entry: { "command" => ARGS.first, "args" => ARGS.drop(1) } },
|
|
19
|
+
"opencode" => { file: ".maf/mcp/opencode.json", key: "mcp",
|
|
20
|
+
entry: { "type" => "local", "command" => ARGS, "enabled" => true } }
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.targets = TARGETS
|
|
24
|
+
|
|
25
|
+
GLOBAL_COMMANDS = { "codex" => "codex mcp add %<name>s -- ruby %<vault>s mcp",
|
|
26
|
+
"hermes" => "hermes mcp add %<name>s --command ruby --args %<vault>s mcp" }.freeze
|
|
27
|
+
|
|
28
|
+
# The shell command for a harness with a global MCP config, or nil.
|
|
29
|
+
def self.global_command(harness, project)
|
|
30
|
+
vault = File.join(project, ".maf", "bin", "vault")
|
|
31
|
+
GLOBAL_COMMANDS[harness]&.then { |text| format(text, name: "#{NAME}-#{File.basename(project)}", vault: vault) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(project, harness)
|
|
35
|
+
@target = TARGETS.fetch(harness)
|
|
36
|
+
@path = File.join(project, @target[:file])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns :create, :skip, or :refuse.
|
|
40
|
+
def write
|
|
41
|
+
data = load
|
|
42
|
+
return :refuse unless data
|
|
43
|
+
|
|
44
|
+
section = data.fetch(@target[:key], {})
|
|
45
|
+
section.key?(NAME) ? compare(section[NAME]) : add(data, section)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def compare(entry) = entry == @target[:entry] ? :skip : :refuse
|
|
51
|
+
|
|
52
|
+
def add(data, section)
|
|
53
|
+
save(data.merge(@target[:key] => section.merge(NAME => @target[:entry])))
|
|
54
|
+
:create
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An unreadable file gives nil. maf never overwrites a file it cannot read.
|
|
58
|
+
def load
|
|
59
|
+
data = File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
60
|
+
data.is_a?(Hash) ? data : nil
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def save(data)
|
|
66
|
+
data = { "$schema" => SCHEMA }.merge(data) if @target[:key] == "mcp" && !data.key?("$schema")
|
|
67
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
68
|
+
File.write(@path, "#{JSON.pretty_generate(data)}\n")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# McpInstaller adds the graphify MCP server for each harness of the run.
|
|
6
|
+
# The key "mcp": false in .maf/config.json turns it off.
|
|
7
|
+
class McpInstaller
|
|
8
|
+
def initialize(options)
|
|
9
|
+
@options = options
|
|
10
|
+
@project = options.project
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def install
|
|
14
|
+
return if @options.check? || disabled?
|
|
15
|
+
|
|
16
|
+
@options.agents.map { |a| a[:harness] }.uniq.each { |harness| install_for(harness) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def install_for(harness)
|
|
22
|
+
if McpConfig.targets.key?(harness)
|
|
23
|
+
report(harness, McpConfig.new(@project, harness).write)
|
|
24
|
+
elsif (command = McpConfig.global_command(harness, @project))
|
|
25
|
+
puts "mcp: the #{harness} harness keeps MCP servers in a global file. Add the server yourself:\n #{command}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def report(harness, status)
|
|
30
|
+
file = McpConfig.targets.fetch(harness).fetch(:file)
|
|
31
|
+
return puts("mcp: wrote the graphify server to #{file}") if status == :create
|
|
32
|
+
return unless status == :refuse
|
|
33
|
+
|
|
34
|
+
warn "mcp: #{file} has another graphify entry, or it is not valid JSON. maf left the file as it is."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def disabled?
|
|
38
|
+
path = File.join(@project, ".maf", "config.json")
|
|
39
|
+
File.exist?(path) && JSON.parse(File.read(path))["mcp"] == false
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Maf
|
|
6
|
+
module Flow
|
|
7
|
+
# Models finds the installed harnesses and the models that each one offers.
|
|
8
|
+
# maf add shows them as choices and warns about a model name that the
|
|
9
|
+
# harness does not know: a typo makes every dispatched run fail.
|
|
10
|
+
# A list is nil when maf cannot read it. Then every name counts as known.
|
|
11
|
+
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
|
+
# Without any harness on PATH, maf shows all of them: the user may install one later.
|
|
20
|
+
def self.installed
|
|
21
|
+
found = HARNESSES.select { |harness| on_path?(harness) }
|
|
22
|
+
found.empty? ? HARNESSES : found
|
|
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)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.for(harness)
|
|
30
|
+
@lists ||= {}
|
|
31
|
+
return @lists[harness] if @lists.key?(harness)
|
|
32
|
+
|
|
33
|
+
@lists[harness] = { "claude" => -> { CLAUDE }, "codex" => -> { codex }, "opencode" => -> { opencode } }
|
|
34
|
+
.fetch(harness, -> {}).call
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.known?(harness, model)
|
|
38
|
+
return model.match?(CLAUDE_NAME) if harness == "claude"
|
|
39
|
+
|
|
40
|
+
self.for(harness).nil? || self.for(harness).include?(model)
|
|
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
|
|
50
|
+
|
|
51
|
+
def self.opencode
|
|
52
|
+
return nil unless on_path?("opencode")
|
|
53
|
+
|
|
54
|
+
out, status = Open3.capture2("opencode", "models", err: File::NULL)
|
|
55
|
+
status.success? && !out.strip.empty? ? out.split : nil
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# Options parses the command line and holds the settings of one run.
|
|
6
|
+
class Options
|
|
7
|
+
attr_accessor :project, :agents
|
|
8
|
+
attr_reader :removed, :models, :hermes_dir, :list_roles
|
|
9
|
+
|
|
10
|
+
# Each flag: the OptionParser arguments, and a block that runs in the
|
|
11
|
+
# Options instance with the flag value.
|
|
12
|
+
FLAGS = [
|
|
13
|
+
[["--project DIR"], ->(v) { @project = v }],
|
|
14
|
+
[["--agent SPEC", "HARNESS:ROLE[:MODEL]"], ->(v) { @agents << parse_agent(v) }],
|
|
15
|
+
[["--remove SPEC", "HARNESS:ROLE"], ->(v) { @removed << parse_agent(v) }],
|
|
16
|
+
[["--model PAIR", "ROLE=MODEL"], ->(v) { @models.store(*v.split("=", 2)) }],
|
|
17
|
+
[["--hermes-dir DIR"], ->(v) { @hermes_dir = File.expand_path(v) }],
|
|
18
|
+
[["--check"], ->(_) { @check = true }],
|
|
19
|
+
[["--force"], ->(_) { @force = true }],
|
|
20
|
+
[["--no-bootstrap"], ->(_) { @bootstrap = false }],
|
|
21
|
+
[["--list-roles"], ->(_) { @list_roles = true }]
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(argv)
|
|
25
|
+
@argv, @agents, @removed, @models = argv, [], [], {}
|
|
26
|
+
@hermes_dir = DEFAULT_HERMES_DIR
|
|
27
|
+
@check = @force = @list_roles = false
|
|
28
|
+
@bootstrap = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check? = @check
|
|
32
|
+
def force? = @force
|
|
33
|
+
def bootstrap? = @bootstrap
|
|
34
|
+
|
|
35
|
+
def parse
|
|
36
|
+
parser.parse!(@argv)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Flags that take a value. maf uses them to tell flag values from agent specs.
|
|
41
|
+
def value_flags
|
|
42
|
+
parser.top.list.grep(OptionParser::Switch::RequiredArgument).flat_map(&:long)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def model_for(agent)
|
|
46
|
+
agent[:model] || @models[agent[:role]] || agent[:saved_model] || DEFAULT_MODELS[agent[:harness]]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parser
|
|
52
|
+
OptionParser.new do |o|
|
|
53
|
+
o.banner = "Usage: maf add|remove|update [HARNESS:ROLE ...] [options]"
|
|
54
|
+
FLAGS.each { |args, set| o.on(*args) { |value| instance_exec(value, &set) } }
|
|
55
|
+
o.on("-h", "--help") { puts o; exit 0 }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_agent(spec)
|
|
60
|
+
harness, role, model = spec.split(":", 3)
|
|
61
|
+
{ harness: harness, role: role, model: model }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# PromptBuilder builds the prompt text of one role file.
|
|
6
|
+
class PromptBuilder
|
|
7
|
+
def initialize(roles, agents, workflow = nil)
|
|
8
|
+
@roles = roles
|
|
9
|
+
@agents = agents
|
|
10
|
+
@workflow = workflow
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build(harness, role, data) = "#{role_prompt(harness, role, data).rstrip}\n\n#{CONTRACT}\n"
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def role_prompt(harness, role, data)
|
|
18
|
+
return project_manager_prompt(harness, data) if role == "project-manager"
|
|
19
|
+
return architect_prompt(harness, data) if role == "architect"
|
|
20
|
+
|
|
21
|
+
worker_prompt(harness, role, data)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def worker_prompt(harness, role, data)
|
|
25
|
+
loop_text = format(WORKER_LOOP, role: role, no_task_instruction: no_task_line(harness))
|
|
26
|
+
"#{intro(data)}\n\n#{duties_block(data)}\n\n#{loop_text}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Claude Code wakes an idle session with the board-watch hook, and opencode
|
|
30
|
+
# with the board-watch plugin. So these agents stop instead of waiting:
|
|
31
|
+
# each return of a wait costs one model call over the whole context.
|
|
32
|
+
# Codex waits in its stop hook after `coord await`. Hermes cannot be
|
|
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
|
|
40
|
+
|
|
41
|
+
def architect_prompt(harness, data)
|
|
42
|
+
loop_text = project_manager? ? ARCHITECT_LOOP_PM : ARCHITECT_LOOP_DIRECT
|
|
43
|
+
"#{intro(data)} You do not implement code yourself.\n\n#{duties_block(data)}\n\n" \
|
|
44
|
+
"#{format(loop_text, roles: dispatch_roles_text)}#{claude_rule(harness)}#{workflow_block}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The loop text ends with a newline, so the rule lands as the last rule line.
|
|
48
|
+
def claude_rule(harness) = harness == "claude" ? "#{CLAUDE_ARCHITECT_RULE}\n" : ""
|
|
49
|
+
|
|
50
|
+
# The workflow goes into the orchestrator prompt only. Workers stay workflow-blind.
|
|
51
|
+
def workflow_block = @workflow ? "\n\n#{@workflow}" : ""
|
|
52
|
+
|
|
53
|
+
def project_manager?
|
|
54
|
+
@agents.any? { |a| a[:role] == "project-manager" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def project_manager_prompt(harness, data)
|
|
58
|
+
"#{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, PM_WAIT_DEFAULT))}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def intro(data)
|
|
63
|
+
"You are the #{data.fetch("title")} for this project."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def duties_block(data)
|
|
67
|
+
lines = data.fetch("duties").strip.lines.map { |line| line.strip.empty? ? line : " #{line}" }
|
|
68
|
+
"Duties:\n#{lines.join}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# What the architect can dispatch to: this run's worker roles, not every
|
|
72
|
+
# role in roles.yml. Advertising a role nobody generated a session for
|
|
73
|
+
# means tasks pile up unclaimed forever.
|
|
74
|
+
def dispatch_roles
|
|
75
|
+
@agents.map { |a| a[:role] }.uniq - DISPATCH_EXCLUDE
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def dispatch_roles_text
|
|
79
|
+
return " (none requested yet in this run)" if dispatch_roles.empty?
|
|
80
|
+
|
|
81
|
+
dispatch_roles.map { |r| " - #{r}: #{@roles.fetch(r).fetch("description")}" }.join("\n")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|