maf 0.1.0 → 0.3.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 +174 -0
- data/README.md +29 -11
- data/assets/agents-contract.md +2 -0
- data/assets/analyst +29 -51
- data/assets/coord +168 -41
- data/assets/dashboard +1 -5
- data/assets/dispatcher +127 -298
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/install.md +6 -1
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/cli.rb +22 -9
- 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/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +3 -2
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +3 -3
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +14 -38
- data/lib/maf/flow/options.rb +33 -5
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +13 -14
- data/lib/maf/flow/report.rb +8 -9
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +7 -22
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +7 -21
- data/lib/maf/menu.rb +49 -15
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +3 -3
- data/lib/maf/setup_agent/launcher.rb +16 -65
- data/lib/maf/setup_agent/manifest.rb +9 -3
- 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 +11 -13
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +147 -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 +137 -0
- data/lib/maf/shared/harnesses/codex.rb +202 -0
- data/lib/maf/shared/harnesses/cursor.rb +120 -0
- data/lib/maf/shared/harnesses/hermes.rb +122 -0
- data/lib/maf/shared/harnesses/opencode.rb +192 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/roles.rb +4 -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.rb +5 -4
- data/lib/maf/team_command.rb +5 -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 +12 -25
- 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 +16 -8
- data/lib/maf/worker_list.rb +83 -0
- data/lib/maf/workers.rb +27 -0
- data/templates/roles.yml +14 -0
- metadata +33 -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,55 @@
|
|
|
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 "json"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Harness
|
|
11
|
+
# Events reads the JSON event lines of a harness run. Each harness takes
|
|
12
|
+
# its session ID from one specific event, never from free text, so a
|
|
13
|
+
# prompt or a handoff note that the agent echoes cannot supply a false ID.
|
|
14
|
+
module Events
|
|
15
|
+
def self.all(output) = output.lines.filter_map { |line| parse(line.strip) }
|
|
16
|
+
|
|
17
|
+
def self.last(output, &match) = all(output).reverse.find(&match) || {}
|
|
18
|
+
|
|
19
|
+
def self.parse(line)
|
|
20
|
+
event = line.start_with?("{") ? JSON.parse(line) : nil
|
|
21
|
+
event.is_a?(Hash) ? event : nil
|
|
22
|
+
rescue JSON::ParserError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Usage turns the token usage of a harness into one shape.
|
|
28
|
+
#
|
|
29
|
+
# input_tokens counts every input token, cache reads included.
|
|
30
|
+
# cached_input_tokens counts the cache reads, which cost a fraction of the
|
|
31
|
+
# input price. cache_write_input_tokens counts the cache writes, which cost
|
|
32
|
+
# more than the input price. Claude Code reports cache reads and cache
|
|
33
|
+
# writes outside its input_tokens, so they are added. Codex reports them inside.
|
|
34
|
+
module Usage
|
|
35
|
+
def self.normalize(data)
|
|
36
|
+
return nil unless data.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
{ "input_tokens" => input(data),
|
|
39
|
+
"cached_input_tokens" => int(data, "cached_input_tokens", "cache_read_input_tokens"),
|
|
40
|
+
"cache_write_input_tokens" => int(data, "cache_write_input_tokens", "cache_creation_input_tokens"),
|
|
41
|
+
"output_tokens" => int(data, "output_tokens", "completion_tokens") }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.input(data)
|
|
45
|
+
int(data, "input_tokens", "prompt_tokens") + int(data, "cache_read_input_tokens") +
|
|
46
|
+
int(data, "cache_creation_input_tokens")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.int(data, *names) = data.values_at(*names).compact.first.to_i
|
|
50
|
+
|
|
51
|
+
def self.sum(list) = list.empty? ? nil : list.reduce { |total, usage| total.merge(usage) { |_, a, b| a + b } }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
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 "base"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Harness
|
|
11
|
+
# ResultEvent reads a run that ends with one JSON result event: the
|
|
12
|
+
# session ID, the result text, and the usage.
|
|
13
|
+
module ResultEvent
|
|
14
|
+
def result_event(output) = Events.last(output) { |event| result_event?(event) }
|
|
15
|
+
def result_event?(event) = event["type"] == "result"
|
|
16
|
+
def result_text(output) = result_event(output)["result"]
|
|
17
|
+
def session_id(output) = present(result_event(output)["session_id"])
|
|
18
|
+
def usage(output) = Usage.normalize(result_event(output)["usage"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# PrependsRole is for a CLI without an agent flag. A fresh session gets
|
|
22
|
+
# the role file text in front of the prompt. A resumed session has it.
|
|
23
|
+
module PrependsRole
|
|
24
|
+
FRONT_MATTER = /\A---\n.*?\n---\n+/m
|
|
25
|
+
|
|
26
|
+
def first_prompt(role, prompt, session_id) = session_id ? prompt : role_text(role) + prompt
|
|
27
|
+
|
|
28
|
+
def role_text(role)
|
|
29
|
+
path = role_path(role)
|
|
30
|
+
path && File.exist?(path) ? "#{File.read(path).sub(FRONT_MATTER, "")}\n\n" : ""
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
|
|
7
|
+
module Maf
|
|
8
|
+
module Shared
|
|
9
|
+
module Harness
|
|
10
|
+
# VmCredential is one login file of a harness that maf vm can copy into the VM.
|
|
11
|
+
class VmCredential
|
|
12
|
+
attr_reader :dest
|
|
13
|
+
|
|
14
|
+
def self.file(path, dest) = new(dest) { File.read(path) if File.file?(path) }
|
|
15
|
+
|
|
16
|
+
def self.keychain(service, dest)
|
|
17
|
+
new(dest) do
|
|
18
|
+
text = IO.popen(["security", "find-generic-password", "-s", service, "-w"], err: File::NULL, &:read)
|
|
19
|
+
text unless text.to_s.strip.empty?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(dest, &read)
|
|
24
|
+
@dest = dest
|
|
25
|
+
@read = read
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read
|
|
29
|
+
@read.call
|
|
30
|
+
rescue SystemCallError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
#
|
|
7
|
+
# harness.rb loads the harness registry and every harness in harnesses/.
|
|
8
|
+
# To add a harness, add one file there. See harness/defaults.rb for the methods.
|
|
9
|
+
require_relative "harness/base"
|
|
10
|
+
require_relative "harness/traits"
|
|
11
|
+
|
|
12
|
+
Dir.glob(File.join(__dir__, "harnesses", "*.rb")).each { |file| require file }
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
EFFORTS = %w[low medium high xhigh max].freeze
|
|
48
|
+
def efforts(_model = nil) = EFFORTS
|
|
49
|
+
|
|
50
|
+
def wait = :watch
|
|
51
|
+
def vm_install = "curl -fsSL https://claude.ai/install.sh | bash"
|
|
52
|
+
LOGIN = ".claude/.credentials.json"
|
|
53
|
+
def vm_credentials = [RUBY_PLATFORM.include?("darwin") ? keychain_login : file_login]
|
|
54
|
+
def keychain_login = Harness::VmCredential.keychain("Claude Code-credentials", LOGIN)
|
|
55
|
+
def file_login = Harness::VmCredential.file(File.join(sandbox_dirs.first, ".credentials.json"), LOGIN)
|
|
56
|
+
|
|
57
|
+
def sandbox_dirs = [ENV.fetch("CLAUDE_CONFIG_DIR", File.join(Dir.home, ".claude"))]
|
|
58
|
+
def sandbox_prefixes = [File.join(Dir.home, ".claude.json")]
|
|
59
|
+
PROTECTED = %w[settings.json settings.local.json CLAUDE.md hooks skills agents commands plugins
|
|
60
|
+
output-styles rules].freeze
|
|
61
|
+
def sandbox_protected = in_dir(sandbox_dirs.first, PROTECTED)
|
|
62
|
+
STATE = %w[projects sessions session-env shell-snapshots file-history todos tasks plans paste-cache statsig
|
|
63
|
+
telemetry ide debug logs cache backups jobs downloads feedback state daemon daemon.log* daemon-auth-*
|
|
64
|
+
history.jsonl* stats-cache.json* *-cache.json* .last-* .credentials* .claude.json*].freeze
|
|
65
|
+
LOGS = ["Library/Caches/claude-cli-nodejs", ".cache/claude-cli-nodejs"].freeze
|
|
66
|
+
def sandbox_state = in_dir(sandbox_dirs.first, STATE) + in_dir(Dir.home, LOGS)
|
|
67
|
+
PROJECT = %w[.mcp.json .claude/settings.json .claude/settings.local.json .claude/hooks .claude/commands
|
|
68
|
+
.claude/skills .claude/agents .claude/output-styles .claude/rules].freeze
|
|
69
|
+
def sandbox_project = PROJECT
|
|
70
|
+
|
|
71
|
+
# Only Claude Code has ScheduleWakeup.
|
|
72
|
+
def architect_rule
|
|
73
|
+
"- Never call ScheduleWakeup with `stop:false` and no `prompt`. " \
|
|
74
|
+
"The call fails. Poll worker status through the task tool instead."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mcp
|
|
78
|
+
{ file: ".maf/mcp/claude.json", key: "mcpServers",
|
|
79
|
+
entry: { "command" => Harness::VAULT_MCP.first, "args" => Harness::VAULT_MCP.drop(1) } }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def legacy_mcp_file = ".mcp.json"
|
|
83
|
+
# An older maf wrote the hooks into the project's .claude/settings.json.
|
|
84
|
+
def legacy_cleaners(project) = [Uninstall::ClaudeSettings.new(project)]
|
|
85
|
+
def bootstrap_dir = ".claude"
|
|
86
|
+
|
|
87
|
+
# The hook commands run in the worktree. The settings file is in the main project.
|
|
88
|
+
def runtime_hooks(_worktree)
|
|
89
|
+
root = Project.root
|
|
90
|
+
settings = File.join(root, Bootstrap::CLAUDE_SETTINGS)
|
|
91
|
+
Bootstrap::ClaudeSettings.new(Bootstrap::Project.new(root, false)).configure(settings)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# --settings adds the hooks of the flow, --mcp-config adds the graph server.
|
|
95
|
+
# --mcp-config takes many values, so `--` ends the options before the prompt.
|
|
96
|
+
def launch(role, model, effort = nil)
|
|
97
|
+
prompt = "Read #{role_path(role)} and follow it exactly. Start your work loop now."
|
|
98
|
+
["claude", *flag_if("--settings", flow_file("claude", "settings.json")),
|
|
99
|
+
*flag_if("--mcp-config", flow_file("mcp", "claude.json")), *model_flag(model), *effort_flag(effort),
|
|
100
|
+
"--", prompt]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `claude -p` prints one JSON object with session_id and result.
|
|
104
|
+
# LEAN starts no MCP server outside --mcp-config and lists no skill.
|
|
105
|
+
# The user's CLAUDE.md and hooks stay.
|
|
106
|
+
LEAN = %w[--strict-mcp-config --disable-slash-commands].freeze
|
|
107
|
+
|
|
108
|
+
def build_command(config, prompt, session_id)
|
|
109
|
+
cmd = %w[claude -p --output-format json --permission-mode bypassPermissions --agent] + [config.role]
|
|
110
|
+
cmd += model_flag(config.model) + effort_flag(config.effort)
|
|
111
|
+
cmd += LEAN if config.lean
|
|
112
|
+
cmd + flag_if("--mcp-config", run_file(config, "mcp", "claude.json")) +
|
|
113
|
+
resume(session_id, "--resume") + ["--", prompt]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def stale_session?(output) = output.include?("No conversation found with session ID")
|
|
117
|
+
def result_event?(event) = event.key?("result")
|
|
118
|
+
# The result names each model that the run used. The main model comes first.
|
|
119
|
+
def model(output) = result_event(output)["modelUsage"]&.keys&.first
|
|
120
|
+
|
|
121
|
+
# A tool_use block, then a tool_result block with the same ID.
|
|
122
|
+
def transcript_calls(id)
|
|
123
|
+
blocks = jsonl(transcript_path(id)).flat_map { |event| Array(event.dig("message", "content")).grep(Hash) }
|
|
124
|
+
sizes = result_sizes(blocks)
|
|
125
|
+
blocks.select { |b| b["type"] == "tool_use" }
|
|
126
|
+
.map { |b| [b["name"], b.dig("input", "command"), sizes[b["id"]]] }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def result_sizes(blocks)
|
|
130
|
+
blocks.select { |b| b["type"] == "tool_result" }.to_h { |b| [b["tool_use_id"], b["content"].to_s.size] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def transcript_path(id) = Dir.glob(File.join(Dir.home, ".claude", "projects", "*", "#{id}.jsonl")).first
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
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 = CodexModels.listed(home)
|
|
56
|
+
# Without a pinned model, Codex runs the model of its config file.
|
|
57
|
+
def efforts(model = nil) = CodexModels.efforts(model || CodexModels.configured(home), home)
|
|
58
|
+
def effort_flag(effort) = effort ? ["-c", "model_reasoning_effort=#{effort.to_s.dump}"] : []
|
|
59
|
+
|
|
60
|
+
# Codex keeps MCP servers in a global user file. maf does not edit it.
|
|
61
|
+
def mcp_command(server, vault) = "codex mcp add #{server} -- ruby #{vault} mcp"
|
|
62
|
+
|
|
63
|
+
def install_hooks(project)
|
|
64
|
+
%w[next-task.rb session-guard.rb].each { |name| Flow::HookFiles.install(project, name) }
|
|
65
|
+
runtime_hooks(project)
|
|
66
|
+
Flow::LegacyCodexHook.new.remove
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def runtime_hooks(worktree) = Flow::CodexHooks.new(worktree).install
|
|
71
|
+
def runtime_files = [".codex/hooks.json"]
|
|
72
|
+
def cleaners(project) = [Uninstall::CodexHooks.new(project)]
|
|
73
|
+
|
|
74
|
+
# An older maf tracked the role files. A hooks file with only the flow hooks belongs to the flow.
|
|
75
|
+
def untrack_paths(root, tracked)
|
|
76
|
+
hooks = tracked.call(".codex/hooks.json").select { |file| Flow::CodexHooks.flow_only?(File.join(root, file)) }
|
|
77
|
+
tracked.call(role_dir) + hooks
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def launch(role, model, effort = nil)
|
|
81
|
+
abort "setup_agent: #{role_path(role)} missing." unless File.exist?(role_path(role))
|
|
82
|
+
|
|
83
|
+
warn_untrusted
|
|
84
|
+
["codex", *no_daemon, "--add-dir", File.join(Project.root, ".maf", "coordination"), *model_flag(model),
|
|
85
|
+
*effort_flag(effort), File.read(role_path(role))]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def no_daemon = codex_help.include?("--no-daemon") ? ["--no-daemon"] : []
|
|
89
|
+
|
|
90
|
+
def codex_help
|
|
91
|
+
IO.popen(%w[codex --help], err: File::NULL, &:read)
|
|
92
|
+
rescue SystemCallError
|
|
93
|
+
""
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
UNTRUSTED = "maf: Codex does not trust the hooks in %<file>s yet. Without the hooks, the session " \
|
|
97
|
+
"records no token usage, and coord await cannot wake it. The sandbox cannot save the trust: " \
|
|
98
|
+
"start codex once in %<root>s without the sandbox, and trust the hooks when Codex asks."
|
|
99
|
+
|
|
100
|
+
def warn_untrusted
|
|
101
|
+
root = File.realpath(Project.root)
|
|
102
|
+
hooks = File.join(root, ".codex", "hooks.json")
|
|
103
|
+
config = File.join(home, "config.toml")
|
|
104
|
+
trusted = File.exist?(config) && File.read(config).include?("#{hooks}:stop:")
|
|
105
|
+
warn format(UNTRUSTED, file: hooks, root: root) if File.exist?(hooks) && !trusted
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The OpenAI cache of Codex is best effort: a probe missed it after 5 idle minutes.
|
|
109
|
+
def cache_window = 300
|
|
110
|
+
|
|
111
|
+
def build_command(config, prompt, session_id)
|
|
112
|
+
cmd = %w[codex exec] + (session_id ? ["resume"] : [])
|
|
113
|
+
cmd += %w[--json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox]
|
|
114
|
+
cmd += model_flag(config.model, "-m") + effort_flag(config.effort)
|
|
115
|
+
cmd += lean_flags if config.lean
|
|
116
|
+
cmd + resume(session_id) + [first_prompt(config.role, prompt, session_id)]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def lean_flags = CodexLean.flags(home)
|
|
120
|
+
def stale_session?(output) = output.include?("no rollout found for thread id")
|
|
121
|
+
|
|
122
|
+
def result_text(output)
|
|
123
|
+
Harness::Events.last(output) { |event| event.dig("item", "type") == "agent_message" }.dig("item", "text")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def session_id(output)
|
|
127
|
+
present(Harness::Events.last(output) { |event| event["type"] == "thread.started" }["thread_id"])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Codex reports the usage of each turn in a turn.completed event.
|
|
131
|
+
def usage(output)
|
|
132
|
+
turns = Harness::Events.all(output).select { |event| event["type"] == "turn.completed" }
|
|
133
|
+
Harness::Usage.sum(turns.filter_map { |event| Harness::Usage.normalize(event["usage"]) })
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def transcript_calls(id)
|
|
137
|
+
path = Dir.glob(File.join(Dir.home, ".codex", "sessions", "**", "rollout-*#{id}.jsonl")).first
|
|
138
|
+
CodexTranscript.calls(jsonl(path).map { |event| event["payload"] || {} })
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# CodexModels reads the model list of the account from the cache file of Codex.
|
|
143
|
+
module CodexModels
|
|
144
|
+
def self.listed(home) = cache(home)&.select { |m| m["visibility"] == "list" }&.map { |m| m["slug"] }
|
|
145
|
+
|
|
146
|
+
# nil: no model, or a model outside the cache.
|
|
147
|
+
def self.efforts(model, home)
|
|
148
|
+
entry = model && cache(home)&.find { |m| m["slug"] == model }
|
|
149
|
+
entry&.fetch("supported_reasoning_levels", nil)&.map { |level| level["effort"] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.configured(home)
|
|
153
|
+
path = File.join(home, "config.toml")
|
|
154
|
+
top = File.exist?(path) ? File.read(path).split(/^\[/, 2).first.to_s : ""
|
|
155
|
+
top[/^model\s*=\s*"([^"]+)"/, 1]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.cache(home)
|
|
159
|
+
JSON.parse(File.read(File.join(home, "models_cache.json"))).fetch("models")
|
|
160
|
+
rescue SystemCallError, JSON::ParserError, KeyError, NoMethodError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# CodexLean drops the parts of the user's Codex setup that a one-shot run
|
|
166
|
+
# does not use. Each one adds tool schemas, skill lists, or MCP calls
|
|
167
|
+
# to every model call. Only the graphify server of this project stays.
|
|
168
|
+
module CodexLean
|
|
169
|
+
FEATURES = %w[plugins apps browser_use computer_use multi_agent].freeze
|
|
170
|
+
SERVER = /^\[mcp_servers\.([\w-]+)\]\s*$/
|
|
171
|
+
|
|
172
|
+
def self.flags(home) = FEATURES.flat_map { |name| ["--disable", name] } + servers(home).flat_map { |s| off(s) }
|
|
173
|
+
def self.off(server) = ["-c", "mcp_servers.#{server}.enabled=false"]
|
|
174
|
+
def self.keep = "graphify-#{File.basename(Project.root)}"
|
|
175
|
+
|
|
176
|
+
def self.servers(home)
|
|
177
|
+
path = File.join(home, "config.toml")
|
|
178
|
+
File.exist?(path) ? File.read(path).scan(SERVER).flatten.uniq - [keep] : []
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# CodexTranscript reads the tool calls of a Codex session: a
|
|
183
|
+
# custom_tool_call or function_call, then its output with the same call_id.
|
|
184
|
+
module CodexTranscript
|
|
185
|
+
def self.calls(payloads)
|
|
186
|
+
sizes = output_sizes(payloads)
|
|
187
|
+
payloads.select { |p| %w[custom_tool_call function_call].include?(p["type"]) }
|
|
188
|
+
.map { |p| [p["name"], command(p), sizes[p["call_id"]]] }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.output_sizes(payloads)
|
|
192
|
+
payloads.select { |p| p["type"].to_s.end_with?("_output") }.to_h { |p| [p["call_id"], p["output"].to_s.size] }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.command(payload)
|
|
196
|
+
text = (payload["input"] || payload["arguments"]).to_s
|
|
197
|
+
text[/cmd["']?\s*:\s*["']((?:[^"'\\]|\\.)*)/, 1] || text
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
# The Cursor CLI (`agent`). The role files in .cursor/agents/ are Cursor
|
|
13
|
+
# subagents. The CLI has no flag that picks one, so maf start names the
|
|
14
|
+
# role file in the prompt, and the dispatcher puts the role text in front
|
|
15
|
+
# of the first prompt. Cursor reads MCP servers only from .cursor/mcp.json,
|
|
16
|
+
# so the flow passes no MCP config. Cursor has no wake-up hook yet.
|
|
17
|
+
class Cursor < Harness::Base
|
|
18
|
+
include Harness::ResultEvent
|
|
19
|
+
include Harness::PrependsRole
|
|
20
|
+
|
|
21
|
+
def command = "agent"
|
|
22
|
+
def role_dir = ".cursor/agents"
|
|
23
|
+
def sandbox_dirs = [File.join(Dir.home, ".cursor"), File.join(Dir.home, ".config", "cursor")]
|
|
24
|
+
PROTECTED = %w[cli-config.json hooks.json mcp.json hooks agents skills plugins rules].freeze
|
|
25
|
+
def sandbox_protected = in_dir(File.join(Dir.home, ".cursor"), PROTECTED)
|
|
26
|
+
def sandbox_placeholders = super - [File.join(Dir.home, ".cursor", "cli-config.json")]
|
|
27
|
+
STATE = %w[.cursor/agent-cli-state.json* .cursor/statsig-cache.json* .cursor/chats .cursor/projects
|
|
28
|
+
.cursor/ai-tracking .config/cursor/auth.json* .config/cursor/*.lock].freeze
|
|
29
|
+
def sandbox_state = in_dir(Dir.home, STATE)
|
|
30
|
+
PROJECT = %w[.cursor/mcp.json .cursor/hooks.json .cursor/hooks .cursor/cli.json .cursor/agents].freeze
|
|
31
|
+
def sandbox_project = PROJECT
|
|
32
|
+
def vm_install = "curl -fsSL https://cursor.com/install | bash"
|
|
33
|
+
def vm_login_hint = "maf vm shell, then NO_OPEN_BROWSER=1 agent login (or export CURSOR_API_KEY)"
|
|
34
|
+
|
|
35
|
+
def role_template = <<~ERB
|
|
36
|
+
---
|
|
37
|
+
# >>> multi-agent-flow >>>
|
|
38
|
+
name: <%= role %>
|
|
39
|
+
description: <%= description %>
|
|
40
|
+
<% if model -%>
|
|
41
|
+
model: <%= Maf::Shared::Harnesses::CursorModels.name(model, effort) %>
|
|
42
|
+
<% end -%>
|
|
43
|
+
readonly: <%= !can_edit %>
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<%= prompt %>
|
|
47
|
+
ERB
|
|
48
|
+
|
|
49
|
+
def launch(role, model, effort = nil)
|
|
50
|
+
warn "maf: Cursor takes the level in the model name. Without a model, maf drops --effort." if effort && !model
|
|
51
|
+
["agent", *model_flag(CursorModels.name(model, effort)),
|
|
52
|
+
"Read #{role_path(role)} and follow it exactly. Start your work loop now."]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `agent -p --output-format json` prints one result object with session_id and result.
|
|
56
|
+
def build_command(config, prompt, session_id)
|
|
57
|
+
%w[agent -p --output-format json --force --trust
|
|
58
|
+
--approve-mcps] + model_flag(CursorModels.name(config.model, config.effort)) +
|
|
59
|
+
resume(session_id, "--resume") + [first_prompt(config.role, prompt, session_id)]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Cursor has no effort flag. The level is a part of the model name, so
|
|
63
|
+
# maf lists each model without its level, and the levels of each model.
|
|
64
|
+
def models = CursorModels.bases
|
|
65
|
+
|
|
66
|
+
def known_model?(model, list = models)
|
|
67
|
+
list.nil? || list.include?(model) || CursorModels.listed.include?(model)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def efforts(model = nil) = CursorModels.efforts(model)
|
|
71
|
+
def effort_required?(model) = CursorModels.effort_required?(model)
|
|
72
|
+
|
|
73
|
+
# A failed run prints no JSON, only an error on stderr. So a resume
|
|
74
|
+
# without a result event and with "not found" is stale. Cursor reports no token usage.
|
|
75
|
+
def stale_session?(output) = result_event(output).empty? && output.match?(/not found/i)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# CursorModels reads `agent --list-models`. A name is BASE-LEVEL or
|
|
79
|
+
# BASE-LEVEL-fast, for example gpt-5.3-codex-high-fast.
|
|
80
|
+
module CursorModels
|
|
81
|
+
LEVELS = %w[low medium high xhigh max].freeze
|
|
82
|
+
LEVEL = /-(#{LEVELS.join("|")})(?=(?:-fast)?\z)/
|
|
83
|
+
|
|
84
|
+
def self.listed
|
|
85
|
+
return @listed if defined?(@listed)
|
|
86
|
+
|
|
87
|
+
@listed = read
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.read
|
|
91
|
+
out, status = Open3.capture2("agent", "--list-models", err: File::NULL)
|
|
92
|
+
status.success? ? out.scan(/^(\S+) - /).flatten : nil
|
|
93
|
+
rescue SystemCallError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.bases = listed&.map { |model| model.sub(LEVEL, "") }&.uniq
|
|
98
|
+
|
|
99
|
+
# nil: maf cannot read the list. A name with a level takes no other level.
|
|
100
|
+
def self.efforts(model)
|
|
101
|
+
return nil unless listed
|
|
102
|
+
return [] if model.nil? || (model.match?(LEVEL) && listed.include?(model))
|
|
103
|
+
|
|
104
|
+
LEVELS.select { |level| listed.include?(name(model, level)) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A base that Cursor does not list by itself runs only with a level.
|
|
108
|
+
def self.effort_required?(model) = !listed.nil? && !listed.include?(model) && !efforts(model).to_a.empty?
|
|
109
|
+
|
|
110
|
+
# A name with a level gets the new level instead.
|
|
111
|
+
def self.name(model, effort)
|
|
112
|
+
return model unless model && effort
|
|
113
|
+
|
|
114
|
+
model = model.sub(LEVEL, "")
|
|
115
|
+
model.end_with?("-fast") ? model.sub(/-fast\z/, "-#{effort}-fast") : "#{model}-#{effort}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|