maf 0.1.0 → 0.2.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +11 -10
  4. data/assets/analyst +29 -51
  5. data/assets/coord +23 -20
  6. data/assets/dashboard +1 -5
  7. data/assets/dispatcher +69 -286
  8. data/assets/harness-hooks/context-watch.rb +5 -4
  9. data/assets/lima/maf.yaml.erb +61 -0
  10. data/lib/maf/bootstrap/script_planner.rb +1 -1
  11. data/lib/maf/cli.rb +3 -0
  12. data/lib/maf/flow/agent_links.rb +2 -2
  13. data/lib/maf/flow/bootstrapper.rb +5 -5
  14. data/lib/maf/flow/codex_hooks.rb +12 -1
  15. data/lib/maf/flow/generator.rb +1 -1
  16. data/lib/maf/flow/harness_linker.rb +2 -2
  17. data/lib/maf/flow/hook_files.rb +6 -0
  18. data/lib/maf/flow/hook_installer.rb +6 -20
  19. data/lib/maf/flow/manifest.rb +2 -2
  20. data/lib/maf/flow/mcp_config.rb +8 -22
  21. data/lib/maf/flow/mcp_installer.rb +3 -3
  22. data/lib/maf/flow/models.rb +6 -41
  23. data/lib/maf/flow/options.rb +4 -2
  24. data/lib/maf/flow/prompt_builder.rb +7 -14
  25. data/lib/maf/flow/prompt_text.rb +7 -11
  26. data/lib/maf/flow/report.rb +2 -4
  27. data/lib/maf/flow/role_files.rb +4 -19
  28. data/lib/maf/flow/validator.rb +2 -2
  29. data/lib/maf/flow.rb +4 -20
  30. data/lib/maf/menu.rb +1 -1
  31. data/lib/maf/migrate/role_files.rb +3 -1
  32. data/lib/maf/retire.rb +42 -6
  33. data/lib/maf/setup_agent/dispatch.rb +1 -1
  34. data/lib/maf/setup_agent/launcher.rb +15 -66
  35. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  36. data/lib/maf/setup_agent/role_file.rb +1 -1
  37. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  38. data/lib/maf/setup_agent.rb +1 -3
  39. data/lib/maf/shared/harness/base.rb +57 -0
  40. data/lib/maf/shared/harness/defaults.rb +140 -0
  41. data/lib/maf/shared/harness/output.rb +55 -0
  42. data/lib/maf/shared/harness/traits.rb +35 -0
  43. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  44. data/lib/maf/shared/harness.rb +12 -0
  45. data/lib/maf/shared/harnesses/claude.rb +134 -0
  46. data/lib/maf/shared/harnesses/codex.rb +173 -0
  47. data/lib/maf/shared/harnesses/cursor.rb +64 -0
  48. data/lib/maf/shared/harnesses/hermes.rb +112 -0
  49. data/lib/maf/shared/harnesses/opencode.rb +169 -0
  50. data/lib/maf/shared/processes.rb +20 -0
  51. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  52. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  53. data/lib/maf/shared/sandbox/links.rb +28 -0
  54. data/lib/maf/shared/sandbox/paths.rb +124 -0
  55. data/lib/maf/shared/sandbox/references.rb +64 -0
  56. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  57. data/lib/maf/shared/sandbox.rb +84 -0
  58. data/lib/maf/team_command.rb +3 -3
  59. data/lib/maf/uninstall/manifest.rb +10 -8
  60. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  61. data/lib/maf/uninstall/notes.rb +13 -2
  62. data/lib/maf/uninstall/role_files.rb +11 -10
  63. data/lib/maf/uninstall/runner.rb +4 -3
  64. data/lib/maf/uninstall/scripts.rb +1 -1
  65. data/lib/maf/uninstall/vm_files.rb +16 -0
  66. data/lib/maf/uninstall.rb +20 -5
  67. data/lib/maf/untrack.rb +6 -26
  68. data/lib/maf/version.rb +1 -1
  69. data/lib/maf/vm/login.rb +52 -0
  70. data/lib/maf/vm/options.rb +41 -0
  71. data/lib/maf/vm/template.rb +44 -0
  72. data/lib/maf/vm.rb +94 -0
  73. data/lib/maf/worker_control.rb +4 -7
  74. data/lib/maf/workers.rb +27 -0
  75. metadata +30 -11
  76. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  77. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  78. data/templates/claude.md.erb +0 -16
  79. data/templates/codex.md.erb +0 -7
  80. data/templates/hermes.md.erb +0 -12
  81. data/templates/opencode.md.erb +0 -24
@@ -18,12 +18,12 @@ module Maf
18
18
 
19
19
  private
20
20
 
21
- # Bootstrap adds the Claude Code hooks only if .claude/ exists, and the
22
- # opencode plugin only if .opencode/ exists. Flow writes the role files
23
- # after bootstrap, so create these dirs first.
21
+ # Bootstrap installs the hooks of a harness only if its folder exists
22
+ # (Harness#bootstrap_dir). Flow writes the role files after bootstrap,
23
+ # so create these dirs first.
24
24
  def make_harness_dirs
25
- used = %w[claude opencode] & @options.agents.map { |a| a[:harness] }
26
- used.each { |harness| FileUtils.mkdir_p(File.join(@options.project, ".#{harness}")) }
25
+ dirs = @options.agents.map { |a| Harness.fetch(a[:harness]).bootstrap_dir }.compact.uniq
26
+ dirs.each { |dir| FileUtils.mkdir_p(File.join(@options.project, dir)) }
27
27
  end
28
28
 
29
29
  def command
@@ -10,6 +10,14 @@ module Maf
10
10
  FILE = ".codex/hooks.json"
11
11
  TIMEOUT = { "timeout" => 3600 }.freeze
12
12
 
13
+ # A hooks file with only the flow hooks belongs to the flow.
14
+ def self.flow_only?(path)
15
+ hooks = JSON.parse(File.read(path)).fetch("hooks", {}).values.flatten.flat_map { |e| e.fetch("hooks", []) }
16
+ (hooks.map { |hook| hook["command"] } - COMMANDS).empty?
17
+ rescue JSON::ParserError, SystemCallError
18
+ false
19
+ end
20
+
13
21
  def initialize(project)
14
22
  @project = project
15
23
  @path = File.join(project, FILE)
@@ -25,8 +33,11 @@ module Maf
25
33
  private
26
34
 
27
35
  def save(data)
36
+ text = JSON.pretty_generate(data)
37
+ return if File.exist?(@path) && File.read(@path) == text
38
+
28
39
  FileUtils.mkdir_p(File.dirname(@path))
29
- File.write(@path, JSON.pretty_generate(data))
40
+ File.write(@path, text)
30
41
  end
31
42
 
32
43
  # A project that tracks its own Codex hooks keeps the file in git.
@@ -37,7 +37,7 @@ module Maf
37
37
  end
38
38
 
39
39
  def finish(results)
40
- pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install
40
+ pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install.any?
41
41
  manifest.write
42
42
  Report.new(@options.project, @roles, pending).print(results)
43
43
  end
@@ -18,8 +18,8 @@ module Maf
18
18
 
19
19
  private
20
20
 
21
- def harnesses = @options.agents.map { |a| a[:harness] }.uniq.select { |h| HARNESS_DIRS.key?(h) }
22
- def dir(harness) = HARNESS_DIRS.fetch(harness)
21
+ def harnesses = @options.agents.map { |a| a[:harness] }.uniq.select { |h| Harness.fetch(h).role_dir }
22
+ def dir(harness) = Harness.fetch(harness).role_dir
23
23
 
24
24
  def link_role_files(harness)
25
25
  return warn_not_linked(harness) if File.symlink?(File.join(@options.project, dir(harness)))
@@ -4,6 +4,12 @@ module Maf
4
4
  module Flow
5
5
  # HookFiles copies a hook script from the assets and makes it executable.
6
6
  module HookFiles
7
+ # Installs a hook script of the assets in .maf/coordination/harness-hooks/ of the project.
8
+ def self.install(project, name)
9
+ dest = File.join(project, ".maf", "coordination", "harness-hooks", name)
10
+ copy(File.join(ASSETS, "harness-hooks", name), dest)
11
+ end
12
+
7
13
  def self.copy(src, dest)
8
14
  FileUtils.mkdir_p(File.dirname(dest))
9
15
  return if File.exist?(dest) && File.read(dest) == File.read(src)
@@ -3,31 +3,17 @@
3
3
  module Maf
4
4
  module Flow
5
5
  # HookInstaller installs the harness hooks that pick up tasks when a
6
- # session ends. Claude Code and opencode get theirs from bootstrap.
6
+ # session ends. Each harness installs its own (Harness#install_hooks).
7
+ # Some harnesses get theirs from bootstrap instead (Harness#bootstrap_dir).
7
8
  class HookInstaller
8
9
  def initialize(agents, project)
9
- @harnesses = agents.map { |a| a[:harness] }.uniq
10
+ names = agents.map { |a| a[:harness] }
11
+ @harnesses = Harness.all.select { |harness| names.include?(harness.name) }
10
12
  @project = project
11
13
  end
12
14
 
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
15
+ # Returns the result of each harness. A truthy result: the hook needs steps from the user.
16
+ def install = @harnesses.map { |harness| harness.install_hooks(@project) }
31
17
  end
32
18
  end
33
19
  end
@@ -24,9 +24,9 @@ module Maf
24
24
 
25
25
  def data
26
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
27
+ # Only record hermes_dir when --hermes-dir sets it: the default is
28
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
29
+ data["hermes_dir"] = @options.hermes_dir if @options.hermes_dir
30
30
  data
31
31
  end
32
32
 
@@ -3,36 +3,22 @@
3
3
  module Maf
4
4
  module Flow
5
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.
6
+ # in .maf/mcp/. The project's own config files stay as they are. maf start
7
+ # and the dispatcher pass the file to the harness (Harness#launch and
8
+ # Harness#build_command). Harness#mcp names the file and the entry.
9
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.
10
+ # A harness with a global MCP config gives a command instead
11
+ # (Harness#mcp_command). maf does not edit that file. It prints the command.
12
12
  class McpConfig
13
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
14
 
28
15
  # The shell command for a harness with a global MCP config, or nil.
29
16
  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) }
17
+ harness.mcp_command("#{NAME}-#{File.basename(project)}", File.join(project, ".maf", "bin", "vault"))
32
18
  end
33
19
 
34
20
  def initialize(project, harness)
35
- @target = TARGETS.fetch(harness)
21
+ @target = harness.mcp
36
22
  @path = File.join(project, @target[:file])
37
23
  end
38
24
 
@@ -63,7 +49,7 @@ module Maf
63
49
  end
64
50
 
65
51
  def save(data)
66
- data = { "$schema" => SCHEMA }.merge(data) if @target[:key] == "mcp" && !data.key?("$schema")
52
+ data = { "$schema" => @target[:schema] }.merge(data) if @target[:schema] && !data.key?("$schema")
67
53
  FileUtils.mkdir_p(File.dirname(@path))
68
54
  File.write(@path, "#{JSON.pretty_generate(data)}\n")
69
55
  end
@@ -13,13 +13,13 @@ module Maf
13
13
  def install
14
14
  return if @options.check? || disabled?
15
15
 
16
- @options.agents.map { |a| a[:harness] }.uniq.each { |harness| install_for(harness) }
16
+ @options.agents.map { |a| a[:harness] }.uniq.each { |name| install_for(Harness.fetch(name)) }
17
17
  end
18
18
 
19
19
  private
20
20
 
21
21
  def install_for(harness)
22
- if McpConfig.targets.key?(harness)
22
+ if harness.mcp
23
23
  report(harness, McpConfig.new(@project, harness).write)
24
24
  elsif (command = McpConfig.global_command(harness, @project))
25
25
  puts "mcp: the #{harness} harness keeps MCP servers in a global file. Add the server yourself:\n #{command}"
@@ -27,7 +27,7 @@ module Maf
27
27
  end
28
28
 
29
29
  def report(harness, status)
30
- file = McpConfig.targets.fetch(harness).fetch(:file)
30
+ file = harness.mcp.fetch(:file)
31
31
  return puts("mcp: wrote the graphify server to #{file}") if status == :create
32
32
  return unless status == :refuse
33
33
 
@@ -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,53 +7,20 @@ 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 = 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)) }
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
- 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
19
+ @lists.key?(harness) ? @lists[harness] : @lists[harness] = Harness[harness]&.models
49
20
  end
50
21
 
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
22
+ def self.known?(harness, model) = Harness[harness]&.known_model?(model, self.for(harness)) != false
23
+ def self.default(harness) = Harness[harness]&.default_model
59
24
  end
60
25
  end
61
26
  end
@@ -23,7 +23,6 @@ module Maf
23
23
 
24
24
  def initialize(argv)
25
25
  @argv, @agents, @removed, @models = argv, [], [], {}
26
- @hermes_dir = DEFAULT_HERMES_DIR
27
26
  @check = @force = @list_roles = false
28
27
  @bootstrap = true
29
28
  end
@@ -32,6 +31,9 @@ module Maf
32
31
  def force? = @force
33
32
  def bootstrap? = @bootstrap
34
33
 
34
+ # The settings that a harness reads for its role file folder.
35
+ def settings = { "hermes_dir" => @hermes_dir }.compact
36
+
35
37
  def parse
36
38
  parser.parse!(@argv)
37
39
  self
@@ -43,7 +45,7 @@ module Maf
43
45
  end
44
46
 
45
47
  def model_for(agent)
46
- agent[:model] || @models[agent[:role]] || agent[:saved_model] || DEFAULT_MODELS[agent[:harness]]
48
+ agent[:model] || @models[agent[:role]] || agent[:saved_model] || Harness[agent[:harness]]&.default_model
47
49
  end
48
50
 
49
51
  private
@@ -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
- # 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
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)}#{claude_rule(harness)}#{workflow_block}"
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 claude_rule(harness) = harness == "claude" ? "#{CLAUDE_ARCHITECT_RULE}\n" : ""
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, PM_WAIT_DEFAULT))}"
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)
@@ -21,8 +21,7 @@ module Maf
21
21
  - Do not use subagents to verify your work.
22
22
  TEXT
23
23
 
24
- # Harnesses with a watcher that wakes an idle session.
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
- "claude" => "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
44
- "opencode" => "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
45
- "codex" => "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."
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.
@@ -74,16 +74,14 @@ module Maf
74
74
  def pm?(result) = result[:agent][:role] == "project-manager"
75
75
 
76
76
  def print_unembeddable(results)
77
- list = results.select { |r| r[:model] && %w[codex hermes].include?(r[:agent][:harness]) }
77
+ list = results.select { |r| r[:model] && model_command(r) }
78
78
  return if list.empty?
79
79
 
80
80
  puts "", "These harnesses cannot embed a model in the role file. Set it in the harness:",
81
81
  list.map { |r| " #{r[:agent][:harness]}:#{r[:agent][:role]} -> #{model_command(r)}" }
82
82
  end
83
83
 
84
- def model_command(result)
85
- result[:agent][:harness] == "codex" ? "codex -m #{result[:model]}" : "hermes model"
86
- end
84
+ def model_command(result) = Harness.fetch(result[:agent][:harness]).model_hint(result[:model])
87
85
  end
88
86
  end
89
87
  end
@@ -19,30 +19,15 @@ module Maf
19
19
  private
20
20
 
21
21
  def generate_agent(agent)
22
- role = agent[:role]
22
+ harness = Harness.fetch(agent[:harness])
23
23
  model = @options.model_for(agent)
24
- dest = destination(agent[:harness], role)
25
- content = render(agent[:harness], role, @roles.fetch(role), model)
24
+ dest = harness.role_destination(@options.project, agent[:role], @options.settings)
25
+ content = render(harness, agent[:role], @roles.fetch(agent[:role]), model)
26
26
  { agent: agent, dest: dest, status: write(dest, content), model: model }
27
27
  end
28
28
 
29
- # Hermes skills live in a global ~/.hermes/skills/ directory, not the
30
- # project. Namespace by project so two projects using the same role
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
29
  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)
30
+ harness.render_role(**template_values(role, data), prompt: @prompts.build(harness, role, data), model: model)
46
31
  end
47
32
 
48
33
  def template_values(role, data)
@@ -28,11 +28,11 @@ module Maf
28
28
  private
29
29
 
30
30
  def check(agent)
31
- abort "flow: unknown harness '#{agent[:harness]}' (use #{HARNESSES.join(", ")})" unless known?(agent)
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) = HARNESSES.include?(agent[:harness])
35
+ def known?(agent) = Harness.known?(agent[:harness])
36
36
  end
37
37
  end
38
38
  end
data/lib/maf/flow.rb CHANGED
@@ -7,7 +7,7 @@
7
7
  # [--model ROLE=MODEL] [--hermes-dir DIR] [--check] [--force] [--no-bootstrap]
8
8
  # [--list-roles]
9
9
  #
10
- # HARNESS is one of: opencode, claude, codex, hermes.
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
- HARNESSES = %w[opencode claude codex hermes].freeze
32
- DEFAULT_HERMES_DIR = File.join(Dir.home, ".hermes", "skills")
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
 
data/lib/maf/menu.rb CHANGED
@@ -73,7 +73,7 @@ module Maf
73
73
  @prompt.confirm?("#{harness} does not list #{answer}. Use it anyway?") ? answer : ask_model(harness, role, models)
74
74
  end
75
75
 
76
- def default_label(harness) = Flow::DEFAULT_MODELS.fetch(harness, "the harness default")
76
+ def default_label(harness) = Flow::Models.default(harness) || "the harness default"
77
77
 
78
78
  def show_models(harness)
79
79
  models = Flow::Models.for(harness).to_a
@@ -11,7 +11,9 @@ module Maf
11
11
  end
12
12
 
13
13
  def steps
14
- Flow::HARNESS_DIRS.flat_map { |harness, dir| files(dir).map { |file| step(harness, dir, file) } }
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
data/lib/maf/retire.rb CHANGED
@@ -7,7 +7,12 @@ require_relative "shared/processes"
7
7
 
8
8
  module Maf
9
9
  class Retire
10
- RUNTIME_PREFIXES = %w[.maf/ .claude/ .opencode/ .codex/].freeze
10
+ NOT_OURS = "maf: pid %<pid>s is not the dispatcher of %<worker>s: another process has the pid now. " \
11
+ "maf does not stop it."
12
+ DISPATCHER_COMMAND = %r{(\A|[\s/])\.maf/bin/dispatcher(\s|\z)}
13
+
14
+ # Changes in these folders belong to the flow, not to the work of the worker.
15
+ def self.runtime_prefixes = [".maf/"] + Maf::Shared::Harness.all.filter_map(&:home_dir).map { |dir| "#{dir}/" }
11
16
 
12
17
  def initialize(root, spec)
13
18
  @root = root
@@ -38,12 +43,42 @@ module Maf
38
43
  def stop_dispatcher
39
44
  pid = Workers.at(@root).find(@worker)&.fetch("pid", nil)
40
45
  return unless pid && Shared::Processes.alive?(pid)
46
+ return warn(format(NOT_OURS, pid: pid, worker: @worker)) unless dispatcher?(pid)
41
47
 
42
48
  puts "Stopping the dispatcher of #{@worker} (pid #{pid}). A running agent finishes first."
43
49
  RunningProcesses.terminate(pid)
44
50
  abort "maf: pid #{pid} did not stop. Stop it with: kill #{pid}" unless RunningProcesses.wait_for_exit(pid)
45
51
  end
46
52
 
53
+ def dispatcher?(pid)
54
+ workers = Workers.at(@root)
55
+ return workers.dispatcher?(@worker) unless workers.started(@worker).to_s.empty?
56
+ return true if legacy_dispatcher?(pid)
57
+
58
+ abort "maf: maf cannot check pid #{pid}. Stop it with: kill #{pid}, then run this again." if beating?(pid)
59
+ false
60
+ end
61
+
62
+ def legacy_dispatcher?(pid)
63
+ command = IO.popen(["ps", "-o", "command=", "-p", pid.to_s], err: File::NULL, &:read).to_s.strip
64
+ command.match?(DISPATCHER_COMMAND) && RunningProcesses.in(@dir).include?(pid.to_i)
65
+ rescue SystemCallError
66
+ false
67
+ end
68
+
69
+ def beating?(pid)
70
+ presence = read_presence
71
+ presence["pid"].to_i == pid.to_i && Shared::Processes.recent?(presence["seen_at"])
72
+ end
73
+
74
+ def read_presence
75
+ JSON.parse(File.read(presence_path))
76
+ rescue SystemCallError, JSON::ParserError
77
+ {}
78
+ end
79
+
80
+ def presence_path = File.join(@root, ".maf", "coordination", "presence", "#{@worker}.json")
81
+
47
82
  def release_tasks
48
83
  env = { "COORD_ROLE" => role, "COORD_WORKER" => @worker }
49
84
  ids = Team.coord(@root, "next", "--mine", env: env).scan(/^[0-9a-f-]{36}/)
@@ -56,10 +91,7 @@ module Maf
56
91
  Team.coord(@root, "annotate", id, "Worker #{@worker} was retired. Continue on the existing task branch.")
57
92
  end
58
93
 
59
- def remove_presence
60
- coord_dir = File.join(@root, ".maf", "coordination")
61
- FileUtils.rm_f(File.join(coord_dir, "presence", "#{@worker}.json"))
62
- end
94
+ def remove_presence = FileUtils.rm_f(presence_path)
63
95
 
64
96
  def remove_worker
65
97
  remove_worktree
@@ -72,7 +104,11 @@ module Maf
72
104
  return false unless Dir.exist?(@dir)
73
105
 
74
106
  lines = IO.popen(["git", "-C", @dir, "status", "--porcelain"], err: File::NULL, &:readlines)
75
- lines.map { |line| line[3..].strip }.any? { |path| RUNTIME_PREFIXES.none? { |prefix| path.start_with?(prefix) } }
107
+ lines.map { |line| line[3..].strip }.any? do |path|
108
+ Retire.runtime_prefixes.none? do |prefix|
109
+ path.start_with?(prefix)
110
+ end
111
+ end
76
112
  end
77
113
 
78
114
  def remove_worktree
@@ -23,7 +23,7 @@ module Maf
23
23
  def self.detach(cmd, worker)
24
24
  log = File.join(ENV.fetch("COORD_DIR"), "sessions", "#{worker}.log")
25
25
  pid = spawn_detached(cmd, log)
26
- Maf::Workers.at(Project.root).update(worker, "pid" => pid)
26
+ Maf::Workers.at(Project.root).update(worker, "pid" => pid, "started" => Maf::Shared::Processes.started_at(pid))
27
27
  puts "Started #{worker} in the background (pid #{pid}). Log: #{log}"
28
28
  end
29
29