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
|
@@ -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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
-
|
|
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}")
|
data/lib/maf/setup_agent.rb
CHANGED
|
@@ -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.
|
|
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 }
|