maf 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +11 -10
  4. data/assets/analyst +29 -51
  5. data/assets/coord +23 -20
  6. data/assets/dashboard +1 -5
  7. data/assets/dispatcher +69 -286
  8. data/assets/harness-hooks/context-watch.rb +5 -4
  9. data/assets/lima/maf.yaml.erb +61 -0
  10. data/lib/maf/bootstrap/script_planner.rb +1 -1
  11. data/lib/maf/cli.rb +3 -0
  12. data/lib/maf/flow/agent_links.rb +2 -2
  13. data/lib/maf/flow/bootstrapper.rb +5 -5
  14. data/lib/maf/flow/codex_hooks.rb +12 -1
  15. data/lib/maf/flow/generator.rb +1 -1
  16. data/lib/maf/flow/harness_linker.rb +2 -2
  17. data/lib/maf/flow/hook_files.rb +6 -0
  18. data/lib/maf/flow/hook_installer.rb +6 -20
  19. data/lib/maf/flow/manifest.rb +2 -2
  20. data/lib/maf/flow/mcp_config.rb +8 -22
  21. data/lib/maf/flow/mcp_installer.rb +3 -3
  22. data/lib/maf/flow/models.rb +6 -41
  23. data/lib/maf/flow/options.rb +4 -2
  24. data/lib/maf/flow/prompt_builder.rb +7 -14
  25. data/lib/maf/flow/prompt_text.rb +7 -11
  26. data/lib/maf/flow/report.rb +2 -4
  27. data/lib/maf/flow/role_files.rb +4 -19
  28. data/lib/maf/flow/validator.rb +2 -2
  29. data/lib/maf/flow.rb +4 -20
  30. data/lib/maf/menu.rb +1 -1
  31. data/lib/maf/migrate/role_files.rb +3 -1
  32. data/lib/maf/retire.rb +42 -6
  33. data/lib/maf/setup_agent/dispatch.rb +1 -1
  34. data/lib/maf/setup_agent/launcher.rb +15 -66
  35. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  36. data/lib/maf/setup_agent/role_file.rb +1 -1
  37. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  38. data/lib/maf/setup_agent.rb +1 -3
  39. data/lib/maf/shared/harness/base.rb +57 -0
  40. data/lib/maf/shared/harness/defaults.rb +140 -0
  41. data/lib/maf/shared/harness/output.rb +55 -0
  42. data/lib/maf/shared/harness/traits.rb +35 -0
  43. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  44. data/lib/maf/shared/harness.rb +12 -0
  45. data/lib/maf/shared/harnesses/claude.rb +134 -0
  46. data/lib/maf/shared/harnesses/codex.rb +173 -0
  47. data/lib/maf/shared/harnesses/cursor.rb +64 -0
  48. data/lib/maf/shared/harnesses/hermes.rb +112 -0
  49. data/lib/maf/shared/harnesses/opencode.rb +169 -0
  50. data/lib/maf/shared/processes.rb +20 -0
  51. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  52. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  53. data/lib/maf/shared/sandbox/links.rb +28 -0
  54. data/lib/maf/shared/sandbox/paths.rb +124 -0
  55. data/lib/maf/shared/sandbox/references.rb +64 -0
  56. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  57. data/lib/maf/shared/sandbox.rb +84 -0
  58. data/lib/maf/team_command.rb +3 -3
  59. data/lib/maf/uninstall/manifest.rb +10 -8
  60. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  61. data/lib/maf/uninstall/notes.rb +13 -2
  62. data/lib/maf/uninstall/role_files.rb +11 -10
  63. data/lib/maf/uninstall/runner.rb +4 -3
  64. data/lib/maf/uninstall/scripts.rb +1 -1
  65. data/lib/maf/uninstall/vm_files.rb +16 -0
  66. data/lib/maf/uninstall.rb +20 -5
  67. data/lib/maf/untrack.rb +6 -26
  68. data/lib/maf/version.rb +1 -1
  69. data/lib/maf/vm/login.rb +52 -0
  70. data/lib/maf/vm/options.rb +41 -0
  71. data/lib/maf/vm/template.rb +44 -0
  72. data/lib/maf/vm.rb +94 -0
  73. data/lib/maf/worker_control.rb +4 -7
  74. data/lib/maf/workers.rb +27 -0
  75. metadata +30 -11
  76. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  77. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  78. data/templates/claude.md.erb +0 -16
  79. data/templates/codex.md.erb +0 -7
  80. data/templates/hermes.md.erb +0 -12
  81. data/templates/opencode.md.erb +0 -24
@@ -1,21 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../shared/sandbox"
4
+ require_relative "pty_relay"
5
+
3
6
  module Maf
4
7
  module SetupAgent
8
+ # Launcher replaces this process with the interactive session of a harness.
9
+ # The harness file builds the command (Maf::Shared::Harnesses).
5
10
  module Launcher
6
- REGISTRY = {}
11
+ def self.launch(harness, role, model)
12
+ ENV.update(harness.launch_env)
13
+ return exec_or_die(harness.launch(role, model)) if Maf::Shared::Sandbox.mode == "off"
7
14
 
8
- def self.for(harness)
9
- register_defaults if REGISTRY.empty?
10
- REGISTRY.fetch(harness) { abort "setup_agent: no launcher for harness '#{harness}' yet." }
15
+ warn "maf: sandbox: #{Maf::Shared::Sandbox.describe(harness)}"
16
+ exit Maf::Shared::Sandbox.keeping_links(harness) { relay_or_die(sandboxed(harness, role, model)) }
11
17
  end
12
18
 
13
- def self.register_defaults
14
- REGISTRY["claude"] = Claude
15
- REGISTRY["opencode"] = Opencode
16
- REGISTRY["codex"] = Codex
17
- REGISTRY["hermes"] = Hermes
18
- end
19
+ def self.sandboxed(harness, role, model) = Maf::Shared::Sandbox.wrap(harness.launch(role, model), harness)
19
20
 
20
21
  def self.exec_or_die(cmd)
21
22
  Kernel.exec(*cmd)
@@ -23,62 +24,10 @@ module Maf
23
24
  abort "setup_agent: '#{cmd.first}' not found on PATH."
24
25
  end
25
26
 
26
- # FlowFiles finds the files of the flow in .maf/ of the main project. The
27
- # project's own harness config stays as it is: each file of the flow is
28
- # passed to the harness at the start.
29
- module FlowFiles
30
- def self.path(*parts) = File.join(Project.root, ".maf", *parts)
31
- def self.flag(name, *parts) = File.exist?(path(*parts)) ? [name, path(*parts)] : []
32
- end
33
-
34
- # --settings adds the hooks of the flow, --mcp-config adds the graph server.
35
- # --mcp-config takes many values, so `--` ends the options before the prompt.
36
- module Claude
37
- def self.launch(role, _worker, model)
38
- prompt = "Read .maf/agents/claude/#{role}.md and follow it exactly. Start your work loop now."
39
- cmd = ["claude", *FlowFiles.flag("--settings", "claude", "settings.json"),
40
- *FlowFiles.flag("--mcp-config", "mcp", "claude.json")]
41
- Launcher.exec_or_die(cmd + (model ? ["--model", model] : []) + ["--", prompt])
42
- end
43
- end
44
-
45
- # opencode merges the file in OPENCODE_CONFIG with the project's opencode.json.
46
- module Opencode
47
- def self.launch(role, _worker, model)
48
- config = FlowFiles.path("mcp", "opencode.json")
49
- ENV["OPENCODE_CONFIG"] = config if File.exist?(config)
50
- cmd = ["opencode", ".", "--agent", role, "--prompt", "Start your work loop now."]
51
- Launcher.exec_or_die(cmd + (model ? ["--model", model] : []))
52
- end
53
- end
54
-
55
- module Codex
56
- def self.launch(role, _worker, model)
57
- prompt_file = ".codex/prompts/#{role}.md"
58
- abort "setup_agent: #{prompt_file} missing." unless File.exist?(prompt_file)
59
-
60
- cmd = ["codex", *writable_dirs]
61
- cmd += ["--model", model] if model
62
- warn_untrusted
63
- Launcher.exec_or_die(cmd + [File.read(prompt_file)])
64
- end
65
-
66
- UNTRUSTED = "maf: Codex does not trust the hooks in %<file>s yet. Trust them when Codex asks. " \
67
- "Without the hooks, the session records no token usage, and coord await cannot wake it."
68
-
69
- # Codex runs the hooks of a project only after the user trusts them, and
70
- # a worktree is a new project for Codex. Codex records the trust in its config.toml.
71
- def self.warn_untrusted
72
- hooks = File.join(File.realpath(Dir.pwd), ".codex", "hooks.json")
73
- config = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
74
- trusted = File.exist?(config) && File.read(config).include?("#{hooks}:stop:")
75
- warn format(UNTRUSTED, file: hooks) if File.exist?(hooks) && !trusted
76
- end
77
-
78
- # The worktree is the only writable root of the Codex sandbox. coord
79
- # writes the board and the artifacts in .maf/coordination of the main
80
- # project, so Codex would ask for approval at each coord command.
81
- def self.writable_dirs = ["--add-dir", File.join(SetupAgent::Project.root, ".maf", "coordination")]
27
+ def self.relay_or_die(cmd)
28
+ PtyRelay.new(cmd).run
29
+ rescue Errno::ENOENT
30
+ abort "setup_agent: '#{cmd.first}' not found on PATH."
82
31
  end
83
32
  end
84
33
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+ require "io/console"
5
+
6
+ module Maf
7
+ module SetupAgent
8
+ # PtyRelay runs a command on its own pseudo terminal and copies the input and the output.
9
+ class PtyRelay
10
+ CHUNK = 4096
11
+ DRAIN = 0.5
12
+ SIGNALS = %w[TERM HUP INT].freeze
13
+ EOF = "\x04"
14
+
15
+ def initialize(cmd) = @cmd = cmd
16
+
17
+ def run
18
+ output, input, pid = PTY.spawn(*@cmd)
19
+ SIGNALS.each { |name| Signal.trap(name) { pass(name, pid) } }
20
+ relay(output, input, Process.detach(pid))
21
+ ensure
22
+ [output, input].compact.each(&:close)
23
+ end
24
+
25
+ private
26
+
27
+ def relay(output, input, waiter)
28
+ resize(input) && Signal.trap("WINCH") { resize(input) }
29
+ copiers = [Thread.new { copy_input(input) }, Thread.new { copy(output, $stdout) }]
30
+ raw { waiter.join }
31
+ stop(copiers)
32
+ waiter.value.exitstatus || 1
33
+ end
34
+
35
+ def stop(copiers)
36
+ copiers.last.join(DRAIN)
37
+ copiers.each(&:kill)
38
+ end
39
+
40
+ def copy(from, to)
41
+ loop { to.write(from.readpartial(CHUNK)) && to.flush }
42
+ rescue IOError, Errno::EIO
43
+ nil
44
+ end
45
+
46
+ # The end of the input is no end on a terminal, so the command gets ^D. Else `echo task | maf start` waits.
47
+ # After a line without a newline, the first ^D only sends the line, so the command gets a second one.
48
+ def copy_input(input)
49
+ last = send_input(input)
50
+ input.write(last.end_with?("\n") ? EOF : EOF * 2)
51
+ rescue IOError, Errno::EIO
52
+ nil
53
+ end
54
+
55
+ def send_input(input)
56
+ last = "\n"
57
+ loop { input.write(last = $stdin.readpartial(CHUNK)) && input.flush }
58
+ rescue EOFError
59
+ last
60
+ end
61
+
62
+ def pass(name, pid)
63
+ Process.kill(name, pid)
64
+ rescue Errno::ESRCH
65
+ nil
66
+ end
67
+
68
+ def raw(&block) = $stdin.tty? ? $stdin.raw(&block) : yield
69
+
70
+ def resize(input)
71
+ $stdout.tty? && (input.winsize = $stdout.winsize)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -8,7 +8,7 @@ module Maf
8
8
  # A harness folder with the project's own files gets one link per role file.
9
9
  module RoleFile
10
10
  def self.copy(root, dir, harness, role)
11
- relative = Flow.role_path(harness, role)
11
+ relative = Maf::Shared::Harness.fetch(harness).role_path(role)
12
12
  return unless relative
13
13
 
14
14
  copy_missing(File.join(root, relative), File.join(dir, relative))
@@ -8,18 +8,7 @@ module Maf
8
8
  module RuntimeHooks
9
9
  def self.install(dir, harness)
10
10
  %w[next-task.rb board-watch.rb session-guard.rb context-watch.rb].each { |name| copy(dir, name) }
11
- configure(dir, harness)
12
- copy(dir, "board-watch-opencode.js", ".opencode/plugins/board-watch.js") if harness == "opencode"
13
- end
14
-
15
- # Claude Code reads the hooks from the settings file of the main project
16
- # (`maf start` passes it with --settings). The hook commands run in the
17
- # worktree, where the copies above live.
18
- def self.configure(dir, harness)
19
- root = Project.root
20
- settings = File.join(root, Bootstrap::CLAUDE_SETTINGS)
21
- Bootstrap::ClaudeSettings.new(Bootstrap::Project.new(root, false)).configure(settings) if harness == "claude"
22
- Flow::CodexHooks.new(dir).install if harness == "codex"
11
+ Maf::Shared::Harness.fetch(harness).runtime_hooks(dir)
23
12
  end
24
13
 
25
14
  def self.copy(dir, name, relative = ".maf/coordination/harness-hooks/#{name}")
@@ -85,7 +85,7 @@ module Maf
85
85
  return Dispatch.launch(args, model) if args.dispatch
86
86
 
87
87
  MafSession.register(Project.root, args.harness)
88
- Launcher.for(args.harness).launch(args.role, args.worker, model)
88
+ Launcher.launch(Maf::Shared::Harness.fetch(args.harness), args.role, model)
89
89
  end
90
90
 
91
91
  # The session and the dispatcher pass the environment to each child, so
@@ -104,8 +104,6 @@ require_relative "setup_agent/role_file"
104
104
  require_relative "setup_agent/manifest"
105
105
  require_relative "setup_agent/worktree"
106
106
  require_relative "setup_agent/launcher"
107
- require_relative "setup_agent/hermes_launcher"
108
107
  require_relative "setup_agent/project"
109
- require_relative "setup_agent/hermes_skill"
110
108
  require_relative "setup_agent/runtime_hooks"
111
109
  require_relative "../../assets/harness-hooks/session-guard"
@@ -0,0 +1,57 @@
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 "erb"
7
+ require "json"
8
+ require_relative "defaults"
9
+ require_relative "output"
10
+
11
+ module Maf
12
+ module Shared
13
+ # Harness is the registry of the agent harnesses. Each file in harnesses/
14
+ # defines one harness: a subclass of Base. The rest of maf asks the
15
+ # registry and never names a harness.
16
+ module Harness
17
+ def self.register(klass) = (@classes ||= []) << klass
18
+ def self.all = @all ||= @classes.map(&:new).sort_by(&:name)
19
+ def self.names = all.map(&:name)
20
+ def self.[](name) = all.find { |harness| harness.name == name }
21
+ def self.fetch(name) = self[name] || raise(KeyError, "unknown harness '#{name}' (use #{names.join(", ")})")
22
+ def self.known?(name) = !self[name].nil?
23
+
24
+ def self.on_path?(command)
25
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
26
+ end
27
+
28
+ # Base is the interface of a harness. A harness file overrides the
29
+ # methods that its CLI needs. Every other method keeps the default here.
30
+ # The methods are grouped by the part of maf that calls them.
31
+ class Base
32
+ def self.inherited(klass)
33
+ super
34
+ Harness.register(klass)
35
+ end
36
+
37
+ # The harness name in `maf add HARNESS:ROLE`. It comes from the class name.
38
+ def name = self.class.name.split("::").last.downcase
39
+ def to_s = name
40
+
41
+ # The CLI of the harness. `maf add` offers only the installed harnesses.
42
+ def command = name
43
+ def installed? = Harness.on_path?(command)
44
+
45
+ include RoleFiles
46
+ include Setup
47
+ include Run
48
+
49
+ # The tool calls of one session, for the analyst: [[name, command, size], ...].
50
+ def transcript_calls(_session_id) = []
51
+
52
+ # The JSON objects of a transcript file, one per line. A broken line is skipped.
53
+ def jsonl(path) = path ? File.foreach(path).filter_map { |line| Events.parse(line.strip) } : []
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,140 @@
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 "../project"
7
+ require_relative "vm_credential"
8
+
9
+ module Maf
10
+ module Shared
11
+ module Harness
12
+ AGENTS_DIR = File.join(".maf", "agents")
13
+ # The command of the graphify MCP server. It runs in the project folder.
14
+ VAULT_MCP = %w[ruby .maf/bin/vault mcp].freeze
15
+
16
+ # RoleFiles: maf add writes one role file per agent.
17
+ module RoleFiles
18
+ # The folder where the harness reads role files. maf links it to
19
+ # .maf/agents/<name>/. nil: the harness reads no role folder.
20
+ def role_dir = nil
21
+ def role_path(role) = role_dir && File.join(AGENTS_DIR, name, "#{role}.md")
22
+
23
+ # settings: the data of .maf/config.json. A harness that keeps role
24
+ # files outside the project reads its folder there.
25
+ def role_destination(project, role, _settings = {}) = role_path(role) && File.join(project, role_path(role))
26
+
27
+ # An ERB template. It gets role, title, description, can_edit, project, prompt, and model.
28
+ def role_template = raise(NotImplementedError, "#{name}: role_template")
29
+ def render_role(values) = ERB.new(role_template, trim_mode: "-").result_with_hash(**values)
30
+
31
+ # nil: the harness lists no models, so every name counts as known.
32
+ def models = nil
33
+ # list: the models of the harness. A caller with a cached list passes it.
34
+ def known_model?(model, list = models) = list.nil? || list.include?(model)
35
+ def default_model = nil
36
+
37
+ # The command that sets the model, for a harness whose role file cannot set it.
38
+ def model_hint(_model) = nil
39
+
40
+ # How an idle agent waits for work. :watch - a watcher wakes the idle
41
+ # session. :await - the stop hook waits after `coord await`.
42
+ # :poll - the agent blocks in `coord next --wait`.
43
+ def wait = :poll
44
+
45
+ # One more rule line for the architect prompt, or nil.
46
+ def architect_rule = nil
47
+ end
48
+
49
+ # Setup: maf add, maf start, bootstrap, untrack, retire, and uninstall.
50
+ module Setup
51
+ # The graphify MCP server in a config file of the flow:
52
+ # { file: ".maf/mcp/x.json", key: "mcpServers", entry: {...} }. nil: no file.
53
+ def mcp = nil
54
+ # The command that adds the server to a global config of the harness, or nil.
55
+ def mcp_command(_server, _vault) = nil
56
+ # The project file where an older maf wrote the MCP server, or nil.
57
+ def legacy_mcp_file = nil
58
+
59
+ # maf add installs the hooks. A truthy result: the user has steps left.
60
+ def install_hooks(_project) = nil
61
+ # maf start refreshes the hooks in the worktree before the session starts.
62
+ def runtime_hooks(_worktree) = nil
63
+ # Bootstrap installs the hooks of the harness only if this folder exists.
64
+ def bootstrap_dir = nil
65
+
66
+ # Uninstall: objects with #steps that remove the files of the flow.
67
+ def cleaners(_project) = []
68
+ # Uninstall and untrack: objects with #steps that remove what an older
69
+ # maf wrote into the project's own files.
70
+ def legacy_cleaners(_project) = []
71
+ # Files outside the project that uninstall keeps: other projects can use them.
72
+ def global_files = []
73
+
74
+ def vm_install = nil
75
+ def vm_credentials = []
76
+ def vm_login_hint = "maf vm shell, then start #{command} and log in"
77
+
78
+ # The top folder of the harness. maf retire ignores changes in it.
79
+ def home_dir = role_dir&.split("/")&.first
80
+ # Files that the flow writes in a worktree. coord keeps them out of git.
81
+ def runtime_files = []
82
+ # Folders that uninstall removes when they are empty. Children come first.
83
+ def empty_dirs = [role_dir, home_dir].compact.uniq
84
+
85
+ # The paths that git tracks for the flow. tracked.call(path) lists the
86
+ # tracked files below path. By default, the links into .maf/agents/.
87
+ def untrack_paths(root, tracked)
88
+ return [] unless role_dir
89
+
90
+ tracked.call(role_dir).select do |path|
91
+ File.symlink?(File.join(root, path)) && File.readlink(File.join(root, path)).include?(AGENTS_DIR)
92
+ end
93
+ end
94
+ end
95
+
96
+ # Run: maf start (an interactive session) and the dispatcher (one run per task).
97
+ module Run
98
+ # The command of an interactive session.
99
+ def launch(_role, _model) = raise(NotImplementedError, "#{name}: launch")
100
+ def launch_env = {}
101
+
102
+ def sandbox_dirs = []
103
+ def sandbox_state = []
104
+ def sandbox_prefixes = []
105
+ def sandbox_protected = []
106
+ def sandbox_project = []
107
+ def in_dir(dir, names) = names.map { |name| File.join(dir, name) }
108
+ def sandbox_placeholders = sandbox_protected.reject { |path| path.end_with?(".md", ".yaml") }
109
+ def sandboxed(cmd) = cmd
110
+
111
+ # The command of one dispatched run. session_id is nil for a fresh session.
112
+ def build_command(_config, _prompt, _session_id) = raise(NotImplementedError, "#{name}: build_command")
113
+ def run_env(_config) = {}
114
+ # The dispatcher calls it once after it parses the options.
115
+ def prepare(config) = config
116
+ # Seconds that the prompt cache of the harness stays warm, or nil for the default.
117
+ def cache_window = nil
118
+ def peak_rate?(_config) = false
119
+
120
+ # The parts of the run output. nil: the harness does not report it.
121
+ def stale_session?(_output) = false
122
+ def session_id(_output) = nil
123
+ def result_text(_output) = nil
124
+ def usage(_output) = nil
125
+ def context(_output) = nil
126
+ def model(_output) = nil
127
+
128
+ # A file of the flow in .maf/ of the main checkout. The dispatcher finds
129
+ # .maf/ next to its coordination folder, which COORD_DIR can move.
130
+ def flow_file(*parts) = File.join(Project.root, ".maf", *parts)
131
+ def run_file(config, *parts) = File.join(File.dirname(config.coord_dir), *parts)
132
+ def flag_if(flag, path) = File.exist?(path) ? [flag, path] : []
133
+
134
+ def present(value) = value.to_s.empty? ? nil : value
135
+ def model_flag(model, flag = "--model") = model ? [flag, model] : []
136
+ def resume(session_id, *flag) = session_id ? [*flag, session_id] : []
137
+ end
138
+ end
139
+ end
140
+ end
@@ -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 }