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
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require_relative "../harness/traits"
7
+
8
+ module Maf
9
+ module Shared
10
+ module Harnesses
11
+ # Claude Code. `--agent` loads .claude/agents/<role>.md. The board-watch
12
+ # hook wakes an idle session. Bootstrap installs the hooks into
13
+ # .maf/claude/settings.json, and maf start passes the file with --settings.
14
+ class Claude < Harness::Base
15
+ include Harness::ResultEvent
16
+
17
+ def role_dir = ".claude/agents"
18
+
19
+ def role_template = <<~ERB
20
+ ---
21
+ # >>> multi-agent-flow >>>
22
+ name: <%= role %>
23
+ description: <%= description %>
24
+ <% if model -%>
25
+ model: <%= model %>
26
+ <% end -%>
27
+ <%# A tools list blocks each MCP tool that it does not name. -%>
28
+ <% if can_edit -%>
29
+ tools: Read, Write, Edit, Bash, Grep, Glob, mcp__graphify
30
+ <% else -%>
31
+ tools: Read, Bash, Grep, Glob, mcp__graphify
32
+ <% end -%>
33
+ ---
34
+
35
+ <%= prompt %>
36
+ ERB
37
+
38
+ # Claude Code has no command that lists models. The aliases follow the
39
+ # newest model of each family. A full ID is claude-<family>-<version>.
40
+ MODELS = %w[claude-opus-5-5 claude-sonnet-5-5 claude-haiku-5-5 claude-fable-5-1 opus sonnet haiku fable].freeze
41
+ MODEL_NAME = /\A(?:(?:opus|sonnet|haiku|fable)(?:\[1m\])?|opusplan|default|best|
42
+ (?:[\w.-]+\.)?(?:anthropic\.)?claude-(?:opus|sonnet|haiku|fable)-\d[\w.:@\[\]-]*)\z/x
43
+
44
+ def models = MODELS
45
+ def known_model?(model, _list = nil) = model.match?(MODEL_NAME)
46
+ def default_model = "claude-opus-5-5"
47
+
48
+ def wait = :watch
49
+ def vm_install = "curl -fsSL https://claude.ai/install.sh | bash"
50
+ LOGIN = ".claude/.credentials.json"
51
+ def vm_credentials = [RUBY_PLATFORM.include?("darwin") ? keychain_login : file_login]
52
+ def keychain_login = Harness::VmCredential.keychain("Claude Code-credentials", LOGIN)
53
+ def file_login = Harness::VmCredential.file(File.join(sandbox_dirs.first, ".credentials.json"), LOGIN)
54
+
55
+ def sandbox_dirs = [ENV.fetch("CLAUDE_CONFIG_DIR", File.join(Dir.home, ".claude"))]
56
+ def sandbox_prefixes = [File.join(Dir.home, ".claude.json")]
57
+ PROTECTED = %w[settings.json settings.local.json CLAUDE.md hooks skills agents commands plugins
58
+ output-styles rules].freeze
59
+ def sandbox_protected = in_dir(sandbox_dirs.first, PROTECTED)
60
+ STATE = %w[projects sessions session-env shell-snapshots file-history todos tasks plans paste-cache statsig
61
+ telemetry ide debug logs cache backups jobs downloads feedback state daemon daemon.log* daemon-auth-*
62
+ history.jsonl* stats-cache.json* *-cache.json* .last-* .credentials* .claude.json*].freeze
63
+ LOGS = ["Library/Caches/claude-cli-nodejs", ".cache/claude-cli-nodejs"].freeze
64
+ def sandbox_state = in_dir(sandbox_dirs.first, STATE) + in_dir(Dir.home, LOGS)
65
+ PROJECT = %w[.mcp.json .claude/settings.json .claude/settings.local.json .claude/hooks .claude/commands
66
+ .claude/skills .claude/agents .claude/output-styles .claude/rules].freeze
67
+ def sandbox_project = PROJECT
68
+
69
+ # Only Claude Code has ScheduleWakeup.
70
+ def architect_rule
71
+ "- Never call ScheduleWakeup with `stop:false` and no `prompt`. " \
72
+ "The call fails. Poll worker status through the task tool instead."
73
+ end
74
+
75
+ def mcp
76
+ { file: ".maf/mcp/claude.json", key: "mcpServers",
77
+ entry: { "command" => Harness::VAULT_MCP.first, "args" => Harness::VAULT_MCP.drop(1) } }
78
+ end
79
+
80
+ def legacy_mcp_file = ".mcp.json"
81
+ # An older maf wrote the hooks into the project's .claude/settings.json.
82
+ def legacy_cleaners(project) = [Uninstall::ClaudeSettings.new(project)]
83
+ def bootstrap_dir = ".claude"
84
+
85
+ # The hook commands run in the worktree. The settings file is in the main project.
86
+ def runtime_hooks(_worktree)
87
+ root = Project.root
88
+ settings = File.join(root, Bootstrap::CLAUDE_SETTINGS)
89
+ Bootstrap::ClaudeSettings.new(Bootstrap::Project.new(root, false)).configure(settings)
90
+ end
91
+
92
+ # --settings adds the hooks of the flow, --mcp-config adds the graph server.
93
+ # --mcp-config takes many values, so `--` ends the options before the prompt.
94
+ def launch(role, model)
95
+ prompt = "Read #{role_path(role)} and follow it exactly. Start your work loop now."
96
+ ["claude", *flag_if("--settings", flow_file("claude", "settings.json")),
97
+ *flag_if("--mcp-config", flow_file("mcp", "claude.json")), *model_flag(model), "--", prompt]
98
+ end
99
+
100
+ # `claude -p` prints one JSON object with session_id and result.
101
+ # LEAN starts no MCP server outside --mcp-config and lists no skill.
102
+ # The user's CLAUDE.md and hooks stay.
103
+ LEAN = %w[--strict-mcp-config --disable-slash-commands].freeze
104
+
105
+ def build_command(config, prompt, session_id)
106
+ cmd = %w[claude -p --output-format json --permission-mode bypassPermissions --agent] + [config.role]
107
+ cmd += model_flag(config.model)
108
+ cmd += LEAN if config.lean
109
+ cmd + flag_if("--mcp-config", run_file(config, "mcp", "claude.json")) +
110
+ resume(session_id, "--resume") + ["--", prompt]
111
+ end
112
+
113
+ def stale_session?(output) = output.include?("No conversation found with session ID")
114
+ def result_event?(event) = event.key?("result")
115
+ # The result names each model that the run used. The main model comes first.
116
+ def model(output) = result_event(output)["modelUsage"]&.keys&.first
117
+
118
+ # A tool_use block, then a tool_result block with the same ID.
119
+ def transcript_calls(id)
120
+ blocks = jsonl(transcript_path(id)).flat_map { |event| Array(event.dig("message", "content")).grep(Hash) }
121
+ sizes = result_sizes(blocks)
122
+ blocks.select { |b| b["type"] == "tool_use" }
123
+ .map { |b| [b["name"], b.dig("input", "command"), sizes[b["id"]]] }
124
+ end
125
+
126
+ def result_sizes(blocks)
127
+ blocks.select { |b| b["type"] == "tool_result" }.to_h { |b| [b["tool_use_id"], b["content"].to_s.size] }
128
+ end
129
+
130
+ def transcript_path(id) = Dir.glob(File.join(Dir.home, ".claude", "projects", "*", "#{id}.jsonl")).first
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require_relative "../harness/traits"
7
+
8
+ module Maf
9
+ module Shared
10
+ module Harnesses
11
+ # Codex. It has no agent flag, so a fresh session gets the role file in
12
+ # front of the prompt. Codex returns a long tool call to the model about
13
+ # every 30 seconds, so an idle agent runs `coord await`, and the stop
14
+ # hook in .codex/hooks.json waits for work outside the model.
15
+ class Codex < Harness::Base
16
+ include Harness::PrependsRole
17
+
18
+ def role_dir = ".codex/prompts"
19
+
20
+ def role_template = <<~ERB
21
+ <!-- >>> multi-agent-flow >>> -->
22
+ # <%= title %>
23
+
24
+ <% if model -%>
25
+ > Model: `<%= model %>` (Codex sets the model at launch: `codex -m <%= model %>`)
26
+ <% end -%>
27
+ <%= prompt %>
28
+ ERB
29
+
30
+ def model_hint(model) = "codex -m #{model}"
31
+ def wait = :await
32
+ def home = ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex"))
33
+ def sandbox_dirs = [home]
34
+ PROTECTED = %w[config.toml hooks.json hooks rules skills plugins prompts packages AGENTS.md
35
+ AGENTS.override.md].freeze
36
+ def sandbox_protected = in_dir(home, PROTECTED)
37
+ def sandbox_placeholders = super - [File.join(home, "packages")]
38
+ STATE = %w[sessions archived_sessions memories log logs* *.sqlite* history.jsonl* session_index.jsonl*
39
+ auth.json* models_cache.json* version.json* installation_id* .sqlite-maintenance.lock tmp .tmp
40
+ shell_snapshots attachments thread-writer-locks tui-*].freeze
41
+ def sandbox_state = in_dir(home, STATE)
42
+ PROJECT = %w[.codex/config.toml .codex/hooks.json .codex/hooks .codex/rules .codex/prompts
43
+ .agents/skills].freeze
44
+ def sandbox_project = PROJECT
45
+ def vm_install = "sudo npm install -g @openai/codex"
46
+ def vm_credentials = [Harness::VmCredential.file(File.join(home, "auth.json"), ".codex/auth.json")]
47
+
48
+ def sandboxed(cmd)
49
+ return cmd if cmd.include?("--dangerously-bypass-approvals-and-sandbox")
50
+
51
+ [cmd.first, "--sandbox", "danger-full-access", *cmd.drop(1)]
52
+ end
53
+
54
+ # Codex keeps the model list of the account in a cache file. Hidden models are internal.
55
+ def models
56
+ models = JSON.parse(File.read(File.join(Dir.home, ".codex", "models_cache.json"))).fetch("models")
57
+ models.select { |m| m["visibility"] == "list" }.map { |m| m["slug"] }
58
+ rescue SystemCallError, JSON::ParserError, KeyError, NoMethodError
59
+ nil
60
+ end
61
+
62
+ # Codex keeps MCP servers in a global user file. maf does not edit it.
63
+ def mcp_command(server, vault) = "codex mcp add #{server} -- ruby #{vault} mcp"
64
+
65
+ def install_hooks(project)
66
+ %w[next-task.rb session-guard.rb].each { |name| Flow::HookFiles.install(project, name) }
67
+ runtime_hooks(project)
68
+ Flow::LegacyCodexHook.new.remove
69
+ nil
70
+ end
71
+
72
+ def runtime_hooks(worktree) = Flow::CodexHooks.new(worktree).install
73
+ def runtime_files = [".codex/hooks.json"]
74
+ def cleaners(project) = [Uninstall::CodexHooks.new(project)]
75
+
76
+ # An older maf tracked the role files. A hooks file with only the flow hooks belongs to the flow.
77
+ def untrack_paths(root, tracked)
78
+ hooks = tracked.call(".codex/hooks.json").select { |file| Flow::CodexHooks.flow_only?(File.join(root, file)) }
79
+ tracked.call(role_dir) + hooks
80
+ end
81
+
82
+ def launch(role, model)
83
+ abort "setup_agent: #{role_path(role)} missing." unless File.exist?(role_path(role))
84
+
85
+ warn_untrusted
86
+ ["codex", "--add-dir", File.join(Project.root, ".maf", "coordination"), *model_flag(model),
87
+ File.read(role_path(role))]
88
+ end
89
+
90
+ UNTRUSTED = "maf: Codex does not trust the hooks in %<file>s yet. Without the hooks, the session " \
91
+ "records no token usage, and coord await cannot wake it. The sandbox cannot save the trust: " \
92
+ "start codex once in %<root>s without the sandbox, and trust the hooks when Codex asks."
93
+
94
+ def warn_untrusted
95
+ root = File.realpath(Project.root)
96
+ hooks = File.join(root, ".codex", "hooks.json")
97
+ config = File.join(home, "config.toml")
98
+ trusted = File.exist?(config) && File.read(config).include?("#{hooks}:stop:")
99
+ warn format(UNTRUSTED, file: hooks, root: root) if File.exist?(hooks) && !trusted
100
+ end
101
+
102
+ # The OpenAI cache of Codex is best effort: a probe missed it after 5 idle minutes.
103
+ def cache_window = 300
104
+
105
+ def build_command(config, prompt, session_id)
106
+ cmd = %w[codex exec] + (session_id ? ["resume"] : [])
107
+ cmd += %w[--json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox]
108
+ cmd += model_flag(config.model, "-m")
109
+ cmd += lean_flags if config.lean
110
+ cmd + resume(session_id) + [first_prompt(config.role, prompt, session_id)]
111
+ end
112
+
113
+ def lean_flags = CodexLean.flags(home)
114
+ def stale_session?(output) = output.include?("no rollout found for thread id")
115
+
116
+ def result_text(output)
117
+ Harness::Events.last(output) { |event| event.dig("item", "type") == "agent_message" }.dig("item", "text")
118
+ end
119
+
120
+ def session_id(output)
121
+ present(Harness::Events.last(output) { |event| event["type"] == "thread.started" }["thread_id"])
122
+ end
123
+
124
+ # Codex reports the usage of each turn in a turn.completed event.
125
+ def usage(output)
126
+ turns = Harness::Events.all(output).select { |event| event["type"] == "turn.completed" }
127
+ Harness::Usage.sum(turns.filter_map { |event| Harness::Usage.normalize(event["usage"]) })
128
+ end
129
+
130
+ def transcript_calls(id)
131
+ path = Dir.glob(File.join(Dir.home, ".codex", "sessions", "**", "rollout-*#{id}.jsonl")).first
132
+ CodexTranscript.calls(jsonl(path).map { |event| event["payload"] || {} })
133
+ end
134
+ end
135
+
136
+ # CodexLean drops the parts of the user's Codex setup that a one-shot run
137
+ # does not use. Each one adds tool schemas, skill lists, or MCP calls
138
+ # to every model call. Only the graphify server of this project stays.
139
+ module CodexLean
140
+ FEATURES = %w[plugins apps browser_use computer_use multi_agent].freeze
141
+ SERVER = /^\[mcp_servers\.([\w-]+)\]\s*$/
142
+
143
+ def self.flags(home) = FEATURES.flat_map { |name| ["--disable", name] } + servers(home).flat_map { |s| off(s) }
144
+ def self.off(server) = ["-c", "mcp_servers.#{server}.enabled=false"]
145
+ def self.keep = "graphify-#{File.basename(Project.root)}"
146
+
147
+ def self.servers(home)
148
+ path = File.join(home, "config.toml")
149
+ File.exist?(path) ? File.read(path).scan(SERVER).flatten.uniq - [keep] : []
150
+ end
151
+ end
152
+
153
+ # CodexTranscript reads the tool calls of a Codex session: a
154
+ # custom_tool_call or function_call, then its output with the same call_id.
155
+ module CodexTranscript
156
+ def self.calls(payloads)
157
+ sizes = output_sizes(payloads)
158
+ payloads.select { |p| %w[custom_tool_call function_call].include?(p["type"]) }
159
+ .map { |p| [p["name"], command(p), sizes[p["call_id"]]] }
160
+ end
161
+
162
+ def self.output_sizes(payloads)
163
+ payloads.select { |p| p["type"].to_s.end_with?("_output") }.to_h { |p| [p["call_id"], p["output"].to_s.size] }
164
+ end
165
+
166
+ def self.command(payload)
167
+ text = (payload["input"] || payload["arguments"]).to_s
168
+ text[/cmd["']?\s*:\s*["']((?:[^"'\\]|\\.)*)/, 1] || text
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require_relative "../harness/traits"
7
+
8
+ module Maf
9
+ module Shared
10
+ module Harnesses
11
+ # The Cursor CLI (`agent`). The role files in .cursor/agents/ are Cursor
12
+ # subagents. The CLI has no flag that picks one, so maf start names the
13
+ # role file in the prompt, and the dispatcher puts the role text in front
14
+ # of the first prompt. Cursor reads MCP servers only from .cursor/mcp.json,
15
+ # so the flow passes no MCP config. Cursor has no wake-up hook yet.
16
+ class Cursor < Harness::Base
17
+ include Harness::ResultEvent
18
+ include Harness::PrependsRole
19
+
20
+ def command = "agent"
21
+ def role_dir = ".cursor/agents"
22
+ def sandbox_dirs = [File.join(Dir.home, ".cursor"), File.join(Dir.home, ".config", "cursor")]
23
+ PROTECTED = %w[cli-config.json hooks.json mcp.json hooks agents skills plugins rules].freeze
24
+ def sandbox_protected = in_dir(File.join(Dir.home, ".cursor"), PROTECTED)
25
+ def sandbox_placeholders = super - [File.join(Dir.home, ".cursor", "cli-config.json")]
26
+ STATE = %w[.cursor/agent-cli-state.json* .cursor/statsig-cache.json* .cursor/chats .cursor/projects
27
+ .cursor/ai-tracking .config/cursor/auth.json* .config/cursor/*.lock].freeze
28
+ def sandbox_state = in_dir(Dir.home, STATE)
29
+ PROJECT = %w[.cursor/mcp.json .cursor/hooks.json .cursor/hooks .cursor/cli.json .cursor/agents].freeze
30
+ def sandbox_project = PROJECT
31
+ def vm_install = "curl -fsSL https://cursor.com/install | bash"
32
+ def vm_login_hint = "maf vm shell, then NO_OPEN_BROWSER=1 agent login (or export CURSOR_API_KEY)"
33
+
34
+ def role_template = <<~ERB
35
+ ---
36
+ # >>> multi-agent-flow >>>
37
+ name: <%= role %>
38
+ description: <%= description %>
39
+ <% if model -%>
40
+ model: <%= model %>
41
+ <% end -%>
42
+ readonly: <%= !can_edit %>
43
+ ---
44
+
45
+ <%= prompt %>
46
+ ERB
47
+
48
+ def launch(role, model)
49
+ ["agent", *model_flag(model), "Read #{role_path(role)} and follow it exactly. Start your work loop now."]
50
+ end
51
+
52
+ # `agent -p --output-format json` prints one result object with session_id and result.
53
+ def build_command(config, prompt, session_id)
54
+ %w[agent -p --output-format json --force --trust --approve-mcps] + model_flag(config.model) +
55
+ resume(session_id, "--resume") + [first_prompt(config.role, prompt, session_id)]
56
+ end
57
+
58
+ # A failed run prints no JSON, only an error on stderr. So a resume
59
+ # without a result event and with "not found" is stale. Cursor reports no token usage.
60
+ def stale_session?(output) = result_event(output).empty? && output.match?(/not found/i)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require_relative "../harness/traits"
7
+ require_relative "../roles"
8
+
9
+ module Maf
10
+ module Shared
11
+ module Harnesses
12
+ # Hermes loads role files as skills from <hermes_dir>/<project>-<role>/,
13
+ # outside the project. `--skills <project>-<role>` loads one. Hermes has
14
+ # no agent flag and cannot be woken, so an idle agent blocks in
15
+ # `coord next --wait`. A role with can_edit false starts without the
16
+ # file-writing toolsets. Hermes keeps hooks and MCP servers in global
17
+ # files, so maf prints the commands that add them.
18
+ class Hermes < Harness::Base
19
+ include Harness::ResultEvent
20
+
21
+ DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
22
+ MAX_TURNS = 50
23
+ # Stop Hermes before the dispatcher kills it, so it can still write its
24
+ # result event. NOTE: assets/dispatcher Spawn::GRACE.
25
+ KILL_GRACE = 5
26
+
27
+ def sandbox_dirs = [File.join(Dir.home, ".hermes")]
28
+ PROTECTED = %w[config.yaml .env shell_init.sh SOUL.md hooks agent-hooks shell-hooks-allowlist.json bin scripts
29
+ cron skills plugins hermes-agent].freeze
30
+ def sandbox_protected = in_dir(File.join(Dir.home, ".hermes"), PROTECTED)
31
+ STATE = %w[sessions logs cache memories state *.db* pastes composer-pastes image_cache audio_cache locks
32
+ pending runtime telemetry terminal-sessions processes.json* spawn-ledger.json* auth.json* auth.lock
33
+ google_token.json* models_dev_cache* provider_models_cache.json* context_length_cache.yaml*
34
+ .hermes_history* .*lock*].freeze
35
+ def sandbox_state = in_dir(File.join(Dir.home, ".hermes"), STATE)
36
+ def sandbox_placeholders = super - [File.join(Dir.home, ".hermes", "hermes-agent")]
37
+ def vm_credentials = [Harness::VmCredential.file(File.join(Dir.home, ".hermes", ".env"), ".hermes/.env")]
38
+
39
+ def role_destination(project, role, settings = {})
40
+ File.join(settings["hermes_dir"] || DEFAULT_DIR, "#{File.basename(project)}-#{role}", "SKILL.md")
41
+ end
42
+
43
+ def role_template = <<~ERB
44
+ ---
45
+ # >>> multi-agent-flow >>>
46
+ name: <%= role %>
47
+ description: <%= description %>
48
+ ---
49
+
50
+ # <%= title %>
51
+
52
+ <% if model -%>
53
+ > Model: `<%= model %>` (Hermes sets the model with `hermes model`)
54
+ <% end -%>
55
+ <%= prompt %>
56
+ ERB
57
+
58
+ def model_hint(_model) = "hermes model"
59
+ def mcp_command(server, vault) = "hermes mcp add #{server} --command ruby --args #{vault} mcp"
60
+ def install_hooks(_project) = Flow::HermesHookSetup.new.install
61
+ def global_files = [File.join(Dir.home, ".hermes", "agent-hooks", "next-task.sh")]
62
+
63
+ # The skill name uses the main checkout's name, from the project root
64
+ # and from a worktree alike.
65
+ def skill_name(role) = "#{File.basename(Project.root)}-#{role}"
66
+
67
+ # The skill that maf add generated for the role, or nil.
68
+ def skill(role)
69
+ dir = Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
70
+ File.exist?(File.join(dir, skill_name(role), "SKILL.md")) ? skill_name(role) : nil
71
+ end
72
+
73
+ def read_only?(role)
74
+ Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
75
+ end
76
+
77
+ def tools(role) = read_only?(role) ? ["-t", Roles::READ_ONLY_TOOLSETS] : []
78
+
79
+ PROMPT = "Follow the coordination rules of your role skill as role %<role>s. " \
80
+ "Run coord inbox, then coord next --wait. Claim a task, do the work, finish it. Repeat."
81
+ LEAD_PROMPT = "Follow your role skill and its coordination rules as role %<role>s. " \
82
+ "You are a lead role. Never claim a task. Run coord inbox --wait and handle each message."
83
+
84
+ # `-q` on a TTY seeds an interactive session with the prompt.
85
+ def launch(role, model)
86
+ prompt = format(Roles::LEADS.include?(role) ? LEAD_PROMPT : PROMPT, role: role)
87
+ ["hermes", "chat", *skill(role)&.then { |name| ["--skills", name] }, *model_flag(model), *tools(role),
88
+ "-q", prompt]
89
+ end
90
+
91
+ # --no-skill drops the skill. Without --skill, the generated skill of the role loads.
92
+ def prepare(config)
93
+ config.skill = nil if config.no_skill
94
+ config.skill ||= skill(config.role) unless config.no_skill
95
+ config.max_turns ||= MAX_TURNS
96
+ config
97
+ end
98
+
99
+ # `hermes chat --oneshot` with JSONL output and the skill preloaded.
100
+ def build_command(config, prompt, session_id)
101
+ cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q]
102
+ cmd += [*(["--skills", config.skill] if config.skill), *model_flag(config.model), *tools(config.role)]
103
+ cmd += ["--max-turns", config.max_turns.to_s, "--run-budget", [config.timeout - KILL_GRACE, 1].max.to_s]
104
+ cmd + resume(session_id, "--resume") + ["-q", prompt]
105
+ end
106
+
107
+ def stale_session?(output) = output.include?("Session not found")
108
+ def result_text(output) = result_event(output)["text"]
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require "open3"
7
+ require_relative "../harness/traits"
8
+
9
+ module Maf
10
+ module Shared
11
+ module Harnesses
12
+ # OpencodeSandbox holds the sandbox paths of opencode.
13
+ module OpencodeSandbox
14
+ DATA_DIRS = %w[.local/share/opencode .local/state/opencode .config/opencode].freeze
15
+ def sandbox_dirs = DATA_DIRS.map { |dir| File.join(Dir.home, dir) }
16
+ PROTECTED = %w[opencode.json opencode.jsonc config.json package.json node_modules plugin plugins agent agents
17
+ command commands skills AGENTS.md].freeze
18
+ def sandbox_protected = in_dir(sandbox_dirs.last, PROTECTED)
19
+ STATE = %w[auth.json* log opencode.db* repos snapshot tool-output storage project].freeze
20
+ def sandbox_state = [*in_dir(sandbox_dirs.first, STATE), File.join(sandbox_dirs[1], "*")]
21
+ def sandbox_project = %w[opencode.json opencode.jsonc .opencode]
22
+ end
23
+
24
+ # opencode. `--agent` loads .opencode/agents/<role>.md. The board-watch
25
+ # plugin in .opencode/plugins/ wakes an idle session. opencode merges the
26
+ # file in OPENCODE_CONFIG with the project's opencode.json.
27
+ class Opencode < Harness::Base
28
+ include OpencodeSandbox
29
+
30
+ def role_dir = ".opencode/agents"
31
+
32
+ def role_template = <<~ERB
33
+ ---
34
+ # >>> multi-agent-flow >>>
35
+ description: <%= description %>
36
+ mode: all
37
+ <% if model -%>
38
+ model: <%= model %>
39
+ <% end -%>
40
+ temperature: 0.1
41
+ permission:
42
+ edit: <%= can_edit ? "allow" : "deny" %>
43
+ bash: allow
44
+ # Worktrees live inside the project. The board, the artifacts, and git
45
+ # data are outside the worktree, so opencode would ask for each access.
46
+ external_directory:
47
+ "/tmp/*": allow
48
+ "/private/tmp/*": allow
49
+ "<%= project %>/.maf/*": allow
50
+ "<%= project %>/.git/*": allow
51
+ task:
52
+ "*": deny
53
+ explore: allow
54
+ ---
55
+
56
+ <%= prompt %>
57
+ ERB
58
+
59
+ def models
60
+ return nil unless installed?
61
+
62
+ out, status = Open3.capture2("opencode", "models", err: File::NULL)
63
+ status.success? && !out.strip.empty? ? out.split : nil
64
+ rescue SystemCallError
65
+ nil
66
+ end
67
+
68
+ def wait = :watch
69
+
70
+ def vm_install = "curl -fsSL https://opencode.ai/install | bash"
71
+ AUTH = ".local/share/opencode/auth.json"
72
+ def vm_credentials = [Harness::VmCredential.file(File.join(Dir.home, AUTH), AUTH)]
73
+
74
+ def mcp
75
+ { file: ".maf/mcp/opencode.json", key: "mcp", schema: "https://opencode.ai/config.json",
76
+ entry: { "type" => "local", "command" => Harness::VAULT_MCP, "enabled" => true } }
77
+ end
78
+
79
+ def legacy_mcp_file = "opencode.json"
80
+ def bootstrap_dir = ".opencode"
81
+
82
+ PLUGIN = ".opencode/plugins/board-watch.js"
83
+
84
+ def runtime_hooks(worktree) = SetupAgent::RuntimeHooks.copy(worktree, "board-watch-opencode.js", PLUGIN)
85
+ def runtime_files = [PLUGIN]
86
+ def empty_dirs = [role_dir, File.dirname(PLUGIN), home_dir]
87
+
88
+ # An older maf tracked the role files and the plugin.
89
+ def untrack_paths(_root, tracked) = tracked.call(role_dir) + tracked.call(PLUGIN)
90
+
91
+ def launch_env = config_env(flow_file("mcp", "opencode.json"))
92
+ def run_env(config) = config_env(run_file(config, "mcp", "opencode.json"))
93
+ def config_env(file) = File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
94
+
95
+ def launch(role, model)
96
+ ["opencode", ".", "--agent", role, "--prompt", "Start your work loop now.", *model_flag(model)]
97
+ end
98
+
99
+ # `opencode run --format json`. The result event shape is not verified,
100
+ # so the log shows no result text.
101
+ def build_command(config, prompt, session_id)
102
+ %w[opencode run --format json --agent] + [config.role] + model_flag(config.model, "-m") +
103
+ resume(session_id, "--session") + [prompt]
104
+ end
105
+
106
+ # DeepSeek costs twice as much at peak hours.
107
+ def peak_rate?(config) = configured_model(config).to_s.include?("deepseek")
108
+
109
+ # The model of a run in the opencode order: --model, the agent file, the project config, the user config.
110
+ CONFIG_MODEL = /^\s*"model"\s*:\s*"([^"]+)"/
111
+ AGENT_MODEL = /\A---\n(?:(?!^---$).)*?^model:\s*(\S+)/m
112
+
113
+ def configured_model(config)
114
+ config.model || find(File.join(role_dir, "#{config.role}.md"), AGENT_MODEL) ||
115
+ config_paths.lazy.filter_map { |path| find(path, CONFIG_MODEL) }.first
116
+ end
117
+
118
+ def config_paths
119
+ home = File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "opencode")
120
+ %w[opencode.json opencode.jsonc] + %w[opencode.jsonc opencode.json].map { |name| File.join(home, name) }
121
+ end
122
+
123
+ def find(path, pattern) = File.exist?(path) ? File.read(path)[pattern, 1] : nil
124
+
125
+ # opencode prints terminal escape codes and can glue two JSON objects
126
+ # onto one line, so the session ID is matched on the start of an event
127
+ # object (captured from a real run), not by parsing whole lines.
128
+ EVENT_SESSION = /\{"type":"[\w.-]+","timestamp":\d+,"sessionID":"([^"]+)"/
129
+ # Each model call ends with a step_finish event. Its tokens.input
130
+ # excludes the cache reads and writes; reasoning is billed as output.
131
+ STEP_FINISH = /\{"type":"step_finish".*/
132
+
133
+ def stale_session?(output) = output.include?("Session not found")
134
+ def session_id(output) = output.scan(EVENT_SESSION).flatten.last
135
+
136
+ def steps(output)
137
+ output.scan(STEP_FINISH).filter_map { |json| Harness::Events.parse(json)&.dig("part", "tokens") }
138
+ end
139
+
140
+ def usage(output) = Harness::Usage.sum(steps(output).map { |tokens| Harness::Usage.normalize(flat(tokens)) })
141
+ def context(output) = steps(output).last&.then { |tokens| Harness::Usage.input(flat(tokens)) }
142
+
143
+ def flat(tokens)
144
+ { "input_tokens" => tokens["input"].to_i, "cache_read_input_tokens" => tokens.dig("cache", "read").to_i,
145
+ "cache_creation_input_tokens" => tokens.dig("cache", "write").to_i,
146
+ "output_tokens" => tokens["output"].to_i + tokens["reasoning"].to_i }
147
+ end
148
+
149
+ # opencode keeps its sessions in a SQLite database. The sqlite3 CLI reads it.
150
+ def database = File.join(Dir.home, ".local", "share", "opencode", "opencode.db")
151
+
152
+ def transcript_calls(id)
153
+ return [] unless File.exist?(database) && id.match?(/\A[\w-]+\z/)
154
+
155
+ out, status = Open3.capture2("sqlite3", "-readonly", "-json", database, transcript_query(id))
156
+ status.success? && !out.empty? ? JSON.parse(out).map(&:values) : []
157
+ rescue SystemCallError, JSON::ParserError
158
+ []
159
+ end
160
+
161
+ def transcript_query(id)
162
+ "select json_extract(data,'$.tool') tool, json_extract(data,'$.state.input.command') command, " \
163
+ "length(json_extract(data,'$.state.output')) size from part where session_id = '#{id}' " \
164
+ "and json_extract(data,'$.type') = 'tool'"
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end