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.
Files changed (136) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +411 -0
  5. data/assets/agents-contract.md +80 -0
  6. data/assets/analyst +240 -0
  7. data/assets/coord +2936 -0
  8. data/assets/dashboard +553 -0
  9. data/assets/dashboard.html +341 -0
  10. data/assets/dispatcher +1687 -0
  11. data/assets/doc-graph-refresh +286 -0
  12. data/assets/env.sh +6 -0
  13. data/assets/git-hooks/post-commit +7 -0
  14. data/assets/git-hooks/post-merge +7 -0
  15. data/assets/git-hooks/pre-commit +32 -0
  16. data/assets/harness-hooks/board-watch-opencode.js +87 -0
  17. data/assets/harness-hooks/board-watch.rb +286 -0
  18. data/assets/harness-hooks/context-watch.rb +268 -0
  19. data/assets/harness-hooks/next-task-hermes.sh +48 -0
  20. data/assets/harness-hooks/next-task.rb +97 -0
  21. data/assets/harness-hooks/session-guard.rb +128 -0
  22. data/assets/taskrc.append +11 -0
  23. data/assets/vault +224 -0
  24. data/assets/worktree-env.example.rb +26 -0
  25. data/exe/maf +14 -0
  26. data/install.md +326 -0
  27. data/lib/maf/bootstrap/claude_settings.rb +55 -0
  28. data/lib/maf/bootstrap/dependencies.rb +37 -0
  29. data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
  30. data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
  31. data/lib/maf/bootstrap/graph_home.rb +62 -0
  32. data/lib/maf/bootstrap/hook_merger.rb +53 -0
  33. data/lib/maf/bootstrap/installer.rb +66 -0
  34. data/lib/maf/bootstrap/layout_planner.rb +18 -0
  35. data/lib/maf/bootstrap/marked_block.rb +44 -0
  36. data/lib/maf/bootstrap/memory_branch.rb +77 -0
  37. data/lib/maf/bootstrap/options.rb +34 -0
  38. data/lib/maf/bootstrap/project.rb +77 -0
  39. data/lib/maf/bootstrap/script_planner.rb +81 -0
  40. data/lib/maf/bootstrap/text_planner.rb +42 -0
  41. data/lib/maf/bootstrap/vault_starter.rb +41 -0
  42. data/lib/maf/bootstrap/writer.rb +69 -0
  43. data/lib/maf/bootstrap.rb +162 -0
  44. data/lib/maf/budget.rb +59 -0
  45. data/lib/maf/cli.rb +135 -0
  46. data/lib/maf/env_exclude.rb +23 -0
  47. data/lib/maf/flow/agent_links.rb +79 -0
  48. data/lib/maf/flow/bootstrapper.rb +36 -0
  49. data/lib/maf/flow/codex_hooks.rb +50 -0
  50. data/lib/maf/flow/generator.rb +63 -0
  51. data/lib/maf/flow/harness_linker.rb +37 -0
  52. data/lib/maf/flow/hermes_hook.rb +48 -0
  53. data/lib/maf/flow/hermes_hook_setup.rb +69 -0
  54. data/lib/maf/flow/hook_files.rb +16 -0
  55. data/lib/maf/flow/hook_installer.rb +33 -0
  56. data/lib/maf/flow/legacy_codex_hook.rb +71 -0
  57. data/lib/maf/flow/manifest.rb +51 -0
  58. data/lib/maf/flow/mcp_config.rb +72 -0
  59. data/lib/maf/flow/mcp_installer.rb +45 -0
  60. data/lib/maf/flow/models.rb +61 -0
  61. data/lib/maf/flow/options.rb +65 -0
  62. data/lib/maf/flow/prompt_builder.rb +85 -0
  63. data/lib/maf/flow/prompt_text.rb +263 -0
  64. data/lib/maf/flow/report.rb +89 -0
  65. data/lib/maf/flow/role_catalog.rb +40 -0
  66. data/lib/maf/flow/role_files.rb +72 -0
  67. data/lib/maf/flow/role_stub.rb +38 -0
  68. data/lib/maf/flow/roster.rb +28 -0
  69. data/lib/maf/flow/validator.rb +38 -0
  70. data/lib/maf/flow/workflow.rb +28 -0
  71. data/lib/maf/flow.rb +84 -0
  72. data/lib/maf/local_exclude.rb +53 -0
  73. data/lib/maf/menu.rb +101 -0
  74. data/lib/maf/migrate/moves.rb +44 -0
  75. data/lib/maf/migrate/rewrites.rb +53 -0
  76. data/lib/maf/migrate/role_files.rb +35 -0
  77. data/lib/maf/migrate/runner.rb +66 -0
  78. data/lib/maf/migrate/worktrees.rb +65 -0
  79. data/lib/maf/migrate.rb +62 -0
  80. data/lib/maf/prompt.rb +40 -0
  81. data/lib/maf/retire.rb +116 -0
  82. data/lib/maf/role_limits.rb +49 -0
  83. data/lib/maf/setup_agent/args.rb +57 -0
  84. data/lib/maf/setup_agent/dispatch.rb +44 -0
  85. data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
  86. data/lib/maf/setup_agent/hermes_skill.rb +26 -0
  87. data/lib/maf/setup_agent/launcher.rb +85 -0
  88. data/lib/maf/setup_agent/manifest.rb +35 -0
  89. data/lib/maf/setup_agent/project.rb +9 -0
  90. data/lib/maf/setup_agent/role_file.rb +30 -0
  91. data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
  92. data/lib/maf/setup_agent/worktree.rb +50 -0
  93. data/lib/maf/setup_agent.rb +111 -0
  94. data/lib/maf/shared/git_exclude.rb +33 -0
  95. data/lib/maf/shared/git_identity.rb +41 -0
  96. data/lib/maf/shared/peak_rate.rb +20 -0
  97. data/lib/maf/shared/processes.rb +31 -0
  98. data/lib/maf/shared/project.rb +34 -0
  99. data/lib/maf/shared/roles.rb +19 -0
  100. data/lib/maf/team.rb +114 -0
  101. data/lib/maf/team_command.rb +73 -0
  102. data/lib/maf/uninstall/claude_settings.rb +40 -0
  103. data/lib/maf/uninstall/codex_hooks.rb +18 -0
  104. data/lib/maf/uninstall/commit_guard.rb +16 -0
  105. data/lib/maf/uninstall/coordination.rb +15 -0
  106. data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
  107. data/lib/maf/uninstall/git.rb +13 -0
  108. data/lib/maf/uninstall/local_files.rb +33 -0
  109. data/lib/maf/uninstall/manifest.rb +29 -0
  110. data/lib/maf/uninstall/marked_files.rb +37 -0
  111. data/lib/maf/uninstall/mcp_entries.rb +43 -0
  112. data/lib/maf/uninstall/notes.rb +31 -0
  113. data/lib/maf/uninstall/owned.rb +12 -0
  114. data/lib/maf/uninstall/role_files.rb +51 -0
  115. data/lib/maf/uninstall/runner.rb +67 -0
  116. data/lib/maf/uninstall/scripts.rb +35 -0
  117. data/lib/maf/uninstall/vault_watcher.rb +21 -0
  118. data/lib/maf/uninstall/worktrees.rb +30 -0
  119. data/lib/maf/uninstall.rb +59 -0
  120. data/lib/maf/untrack.rb +90 -0
  121. data/lib/maf/version.rb +5 -0
  122. data/lib/maf/worker_archive.rb +63 -0
  123. data/lib/maf/worker_control.rb +137 -0
  124. data/lib/maf/workers.rb +37 -0
  125. data/lib/maf.rb +5 -0
  126. data/templates/claude.md.erb +16 -0
  127. data/templates/codex.md.erb +7 -0
  128. data/templates/hermes.md.erb +12 -0
  129. data/templates/opencode.md.erb +24 -0
  130. data/templates/role-stub.yml.erb +15 -0
  131. data/templates/roles.yml +289 -0
  132. data/templates/workflows/panel.md +20 -0
  133. data/templates/workflows/plan-review.md +9 -0
  134. data/templates/workflows/simple.md +4 -0
  135. data/templates/workflows/tdd.md +8 -0
  136. 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