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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +11 -10
- data/assets/analyst +29 -51
- data/assets/coord +23 -20
- data/assets/dashboard +1 -5
- data/assets/dispatcher +69 -286
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/cli.rb +3 -0
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/generator.rb +1 -1
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +2 -2
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +6 -41
- data/lib/maf/flow/options.rb +4 -2
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +7 -11
- data/lib/maf/flow/report.rb +2 -4
- data/lib/maf/flow/role_files.rb +4 -19
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +4 -20
- data/lib/maf/menu.rb +1 -1
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/dispatch.rb +1 -1
- data/lib/maf/setup_agent/launcher.rb +15 -66
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +1 -3
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +140 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +134 -0
- data/lib/maf/shared/harnesses/codex.rb +173 -0
- data/lib/maf/shared/harnesses/cursor.rb +64 -0
- data/lib/maf/shared/harnesses/hermes.rb +112 -0
- data/lib/maf/shared/harnesses/opencode.rb +169 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team_command.rb +3 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +6 -26
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +4 -7
- data/lib/maf/workers.rb +27 -0
- metadata +30 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- data/templates/opencode.md.erb +0 -24
|
@@ -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
|