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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +29 -11
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/analyst +29 -51
  6. data/assets/coord +168 -41
  7. data/assets/dashboard +1 -5
  8. data/assets/dispatcher +127 -298
  9. data/assets/harness-hooks/context-watch.rb +5 -4
  10. data/assets/lima/maf.yaml.erb +61 -0
  11. data/install.md +6 -1
  12. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  13. data/lib/maf/bootstrap/script_planner.rb +1 -1
  14. data/lib/maf/bootstrap.rb +4 -0
  15. data/lib/maf/cli.rb +22 -9
  16. data/lib/maf/flow/agent_links.rb +2 -2
  17. data/lib/maf/flow/bootstrapper.rb +5 -5
  18. data/lib/maf/flow/codex_hooks.rb +12 -1
  19. data/lib/maf/flow/effort_report.rb +20 -0
  20. data/lib/maf/flow/generator.rb +3 -2
  21. data/lib/maf/flow/harness_linker.rb +2 -2
  22. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  23. data/lib/maf/flow/hook_files.rb +6 -0
  24. data/lib/maf/flow/hook_installer.rb +6 -20
  25. data/lib/maf/flow/manifest.rb +3 -3
  26. data/lib/maf/flow/mcp_config.rb +8 -22
  27. data/lib/maf/flow/mcp_installer.rb +3 -3
  28. data/lib/maf/flow/models.rb +14 -38
  29. data/lib/maf/flow/options.rb +33 -5
  30. data/lib/maf/flow/prompt_builder.rb +7 -14
  31. data/lib/maf/flow/prompt_text.rb +13 -14
  32. data/lib/maf/flow/report.rb +8 -9
  33. data/lib/maf/flow/role_catalog.rb +1 -1
  34. data/lib/maf/flow/role_files.rb +7 -22
  35. data/lib/maf/flow/roster.rb +6 -3
  36. data/lib/maf/flow/validator.rb +2 -2
  37. data/lib/maf/flow.rb +7 -21
  38. data/lib/maf/menu.rb +49 -15
  39. data/lib/maf/migrate/role_files.rb +3 -1
  40. data/lib/maf/retire.rb +42 -6
  41. data/lib/maf/setup_agent/args.rb +16 -10
  42. data/lib/maf/setup_agent/dispatch.rb +3 -3
  43. data/lib/maf/setup_agent/launcher.rb +16 -65
  44. data/lib/maf/setup_agent/manifest.rb +9 -3
  45. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  46. data/lib/maf/setup_agent/role_file.rb +1 -1
  47. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  48. data/lib/maf/setup_agent.rb +11 -13
  49. data/lib/maf/shared/harness/base.rb +57 -0
  50. data/lib/maf/shared/harness/defaults.rb +147 -0
  51. data/lib/maf/shared/harness/output.rb +55 -0
  52. data/lib/maf/shared/harness/traits.rb +35 -0
  53. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  54. data/lib/maf/shared/harness.rb +12 -0
  55. data/lib/maf/shared/harnesses/claude.rb +137 -0
  56. data/lib/maf/shared/harnesses/codex.rb +202 -0
  57. data/lib/maf/shared/harnesses/cursor.rb +120 -0
  58. data/lib/maf/shared/harnesses/hermes.rb +122 -0
  59. data/lib/maf/shared/harnesses/opencode.rb +192 -0
  60. data/lib/maf/shared/processes.rb +20 -0
  61. data/lib/maf/shared/roles.rb +4 -0
  62. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  63. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  64. data/lib/maf/shared/sandbox/links.rb +28 -0
  65. data/lib/maf/shared/sandbox/paths.rb +124 -0
  66. data/lib/maf/shared/sandbox/references.rb +64 -0
  67. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  68. data/lib/maf/shared/sandbox.rb +84 -0
  69. data/lib/maf/team.rb +5 -4
  70. data/lib/maf/team_command.rb +5 -3
  71. data/lib/maf/uninstall/manifest.rb +10 -8
  72. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  73. data/lib/maf/uninstall/notes.rb +13 -2
  74. data/lib/maf/uninstall/role_files.rb +11 -10
  75. data/lib/maf/uninstall/runner.rb +4 -3
  76. data/lib/maf/uninstall/scripts.rb +1 -1
  77. data/lib/maf/uninstall/vm_files.rb +16 -0
  78. data/lib/maf/uninstall.rb +20 -5
  79. data/lib/maf/untrack.rb +12 -25
  80. data/lib/maf/version.rb +1 -1
  81. data/lib/maf/vm/login.rb +52 -0
  82. data/lib/maf/vm/options.rb +41 -0
  83. data/lib/maf/vm/template.rb +44 -0
  84. data/lib/maf/vm.rb +94 -0
  85. data/lib/maf/worker_control.rb +16 -8
  86. data/lib/maf/worker_list.rb +83 -0
  87. data/lib/maf/workers.rb +27 -0
  88. data/templates/roles.yml +14 -0
  89. metadata +33 -11
  90. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  91. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  92. data/templates/claude.md.erb +0 -16
  93. data/templates/codex.md.erb +0 -7
  94. data/templates/hermes.md.erb +0 -12
  95. 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