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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require_relative "../harness/traits"
|
|
7
|
+
require_relative "../roles"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
module Shared
|
|
11
|
+
module Harnesses
|
|
12
|
+
# Hermes loads role files as skills from <hermes_dir>/<project>-<role>/,
|
|
13
|
+
# outside the project. `--skills <project>-<role>` loads one. Hermes has
|
|
14
|
+
# no agent flag and cannot be woken, so an idle agent blocks in
|
|
15
|
+
# `coord next --wait`. A role with can_edit false starts without the
|
|
16
|
+
# file-writing toolsets. No role gets the kanban or delegation
|
|
17
|
+
# toolset. Hermes keeps hooks and MCP servers in global
|
|
18
|
+
# files, so maf prints the commands that add them.
|
|
19
|
+
class Hermes < Harness::Base
|
|
20
|
+
include Harness::ResultEvent
|
|
21
|
+
|
|
22
|
+
DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
|
|
23
|
+
MAX_TURNS = 50
|
|
24
|
+
|
|
25
|
+
def sandbox_dirs = [File.join(Dir.home, ".hermes")]
|
|
26
|
+
PROTECTED = %w[config.yaml .env shell_init.sh SOUL.md hooks agent-hooks shell-hooks-allowlist.json bin scripts
|
|
27
|
+
cron skills plugins hermes-agent].freeze
|
|
28
|
+
def sandbox_protected = in_dir(File.join(Dir.home, ".hermes"), PROTECTED)
|
|
29
|
+
STATE = %w[sessions logs cache memories state *.db* pastes composer-pastes image_cache audio_cache locks
|
|
30
|
+
pending runtime telemetry terminal-sessions processes.json* spawn-ledger.json* auth.json* auth.lock
|
|
31
|
+
google_token.json* models_dev_cache* provider_models_cache.json* context_length_cache.yaml*
|
|
32
|
+
.hermes_history* .*lock*].freeze
|
|
33
|
+
def sandbox_state = in_dir(File.join(Dir.home, ".hermes"), STATE)
|
|
34
|
+
def sandbox_placeholders = super - [File.join(Dir.home, ".hermes", "hermes-agent")]
|
|
35
|
+
def vm_credentials = [Harness::VmCredential.file(File.join(Dir.home, ".hermes", ".env"), ".hermes/.env")]
|
|
36
|
+
|
|
37
|
+
def role_destination(project, role, settings = {})
|
|
38
|
+
File.join(settings["hermes_dir"] || DEFAULT_DIR, "#{File.basename(project)}-#{role}", "SKILL.md")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def role_template = <<~ERB
|
|
42
|
+
---
|
|
43
|
+
# >>> multi-agent-flow >>>
|
|
44
|
+
name: <%= role %>
|
|
45
|
+
description: <%= description %>
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# <%= title %>
|
|
49
|
+
|
|
50
|
+
<% if model -%>
|
|
51
|
+
> Model: `<%= model %>` (Hermes sets the model with `hermes model`)
|
|
52
|
+
<% end -%>
|
|
53
|
+
<%= prompt %>
|
|
54
|
+
ERB
|
|
55
|
+
|
|
56
|
+
def model_hint(_model) = "hermes model"
|
|
57
|
+
EFFORTS = %w[none minimal low medium high xhigh max ultra].freeze
|
|
58
|
+
def efforts(_model = nil) = EFFORTS
|
|
59
|
+
def effort_flag(effort) = super(effort, "--reasoning")
|
|
60
|
+
def mcp_command(server, vault) = "hermes mcp add #{server} --command ruby --args #{vault} mcp"
|
|
61
|
+
def install_hooks(_project) = Flow::HermesHookSetup.new.install | Flow::HermesEnvSetup.new.install
|
|
62
|
+
def global_files = [File.join(Dir.home, ".hermes", "agent-hooks", "next-task.sh")]
|
|
63
|
+
|
|
64
|
+
# The skill name uses the main checkout's name, from the project root
|
|
65
|
+
# and from a worktree alike.
|
|
66
|
+
def skill_name(role) = "#{File.basename(Project.root)}-#{role}"
|
|
67
|
+
|
|
68
|
+
# The skill that maf add generated for the role, or nil.
|
|
69
|
+
def skill(role)
|
|
70
|
+
dir = Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
|
|
71
|
+
File.exist?(File.join(dir, skill_name(role), "SKILL.md")) ? skill_name(role) : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_only?(role)
|
|
75
|
+
Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# With -t, Hermes loads an MCP server only when the list names its toolset.
|
|
79
|
+
def tools(role)
|
|
80
|
+
toolsets = read_only?(role) ? Roles::READ_ONLY_TOOLSETS : Roles::EDIT_TOOLSETS
|
|
81
|
+
["-t", "#{toolsets},mcp-graphify-#{File.basename(Project.root)}"]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
PROMPT = "Follow the coordination rules of your role skill as role %<role>s. " \
|
|
85
|
+
"Run coord inbox, then coord next --wait. Claim a task, do the work, finish it. Repeat."
|
|
86
|
+
LEAD_PROMPT = "Follow your role skill and its coordination rules as role %<role>s. " \
|
|
87
|
+
"You are a lead role. Never claim a task. Run coord inbox --wait and handle each message."
|
|
88
|
+
|
|
89
|
+
# `-q` on a TTY seeds an interactive session with the prompt.
|
|
90
|
+
def launch(role, model, effort = nil)
|
|
91
|
+
prompt = format(Roles::LEADS.include?(role) ? LEAD_PROMPT : PROMPT, role: role)
|
|
92
|
+
["hermes", "chat", *skill(role)&.then { |name| ["--skills", name] }, *model_flag(model), *effort_flag(effort),
|
|
93
|
+
*tools(role), "-q", prompt]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --no-skill drops the skill. Without --skill, the generated skill of the role loads.
|
|
97
|
+
def prepare(config)
|
|
98
|
+
config.skill = nil if config.no_skill
|
|
99
|
+
config.skill ||= skill(config.role) unless config.no_skill
|
|
100
|
+
config.max_turns ||= MAX_TURNS
|
|
101
|
+
config
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# `hermes chat --oneshot` with JSONL output and the skill preloaded.
|
|
105
|
+
def build_command(config, prompt, session_id)
|
|
106
|
+
cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q]
|
|
107
|
+
cmd += run_flags(config)
|
|
108
|
+
cmd += ["--max-turns", config.max_turns.to_s]
|
|
109
|
+
cmd + resume(session_id, "--resume") + ["-q", prompt]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run_flags(config)
|
|
113
|
+
[*(["--skills", config.skill] if config.skill), *model_flag(config.model), *effort_flag(config.effort),
|
|
114
|
+
*tools(config.role)]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stale_session?(output) = output.include?("Session not found")
|
|
118
|
+
def result_text(output) = result_event(output)["text"]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "open3"
|
|
7
|
+
require_relative "../harness/traits"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
module Shared
|
|
11
|
+
module Harnesses
|
|
12
|
+
# OpencodeSandbox holds the sandbox paths of opencode.
|
|
13
|
+
module OpencodeSandbox
|
|
14
|
+
DATA_DIRS = %w[.local/share/opencode .local/state/opencode .config/opencode].freeze
|
|
15
|
+
def sandbox_dirs = DATA_DIRS.map { |dir| File.join(Dir.home, dir) }
|
|
16
|
+
PROTECTED = %w[opencode.json opencode.jsonc config.json package.json node_modules plugin plugins agent agents
|
|
17
|
+
command commands skills AGENTS.md].freeze
|
|
18
|
+
def sandbox_protected = in_dir(sandbox_dirs.last, PROTECTED)
|
|
19
|
+
STATE = %w[auth.json* log opencode.db* repos snapshot tool-output storage project].freeze
|
|
20
|
+
def sandbox_state = [*in_dir(sandbox_dirs.first, STATE), File.join(sandbox_dirs[1], "*")]
|
|
21
|
+
def sandbox_project = %w[opencode.json opencode.jsonc .opencode]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# opencode. `--agent` loads .opencode/agents/<role>.md. The board-watch
|
|
25
|
+
# plugin in .opencode/plugins/ wakes an idle session. opencode merges the
|
|
26
|
+
# file in OPENCODE_CONFIG with the project's opencode.json.
|
|
27
|
+
class Opencode < Harness::Base
|
|
28
|
+
include OpencodeSandbox
|
|
29
|
+
|
|
30
|
+
def role_dir = ".opencode/agents"
|
|
31
|
+
|
|
32
|
+
def role_template = <<~ERB
|
|
33
|
+
---
|
|
34
|
+
# >>> multi-agent-flow >>>
|
|
35
|
+
description: <%= description %>
|
|
36
|
+
mode: all
|
|
37
|
+
<% if model -%>
|
|
38
|
+
model: <%= model %>
|
|
39
|
+
<% end -%>
|
|
40
|
+
<% if effort -%>
|
|
41
|
+
variant: <%= effort %>
|
|
42
|
+
<% end -%>
|
|
43
|
+
temperature: 0.1
|
|
44
|
+
permission:
|
|
45
|
+
edit: <%= can_edit ? "allow" : "deny" %>
|
|
46
|
+
bash: allow
|
|
47
|
+
# Worktrees live inside the project. The board, the artifacts, and git
|
|
48
|
+
# data are outside the worktree, so opencode would ask for each access.
|
|
49
|
+
external_directory:
|
|
50
|
+
"/tmp/*": allow
|
|
51
|
+
"/private/tmp/*": allow
|
|
52
|
+
"<%= project %>/.maf/*": allow
|
|
53
|
+
"<%= project %>/.git/*": allow
|
|
54
|
+
task:
|
|
55
|
+
"*": deny
|
|
56
|
+
explore: allow
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
<%= prompt %>
|
|
60
|
+
ERB
|
|
61
|
+
|
|
62
|
+
def models
|
|
63
|
+
return nil unless installed?
|
|
64
|
+
|
|
65
|
+
out, status = Open3.capture2("opencode", "models", err: File::NULL)
|
|
66
|
+
status.success? && !out.strip.empty? ? out.split : nil
|
|
67
|
+
rescue SystemCallError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def wait = :watch
|
|
72
|
+
|
|
73
|
+
def vm_install = "curl -fsSL https://opencode.ai/install | bash"
|
|
74
|
+
AUTH = ".local/share/opencode/auth.json"
|
|
75
|
+
def vm_credentials = [Harness::VmCredential.file(File.join(Dir.home, AUTH), AUTH)]
|
|
76
|
+
|
|
77
|
+
def mcp
|
|
78
|
+
{ file: ".maf/mcp/opencode.json", key: "mcp", schema: "https://opencode.ai/config.json",
|
|
79
|
+
entry: { "type" => "local", "command" => Harness::VAULT_MCP, "enabled" => true } }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def legacy_mcp_file = "opencode.json"
|
|
83
|
+
def bootstrap_dir = ".opencode"
|
|
84
|
+
|
|
85
|
+
PLUGIN = ".opencode/plugins/board-watch.js"
|
|
86
|
+
|
|
87
|
+
def runtime_hooks(worktree) = SetupAgent::RuntimeHooks.copy(worktree, "board-watch-opencode.js", PLUGIN)
|
|
88
|
+
def runtime_files = [PLUGIN]
|
|
89
|
+
def empty_dirs = [role_dir, File.dirname(PLUGIN), home_dir]
|
|
90
|
+
|
|
91
|
+
# An older maf tracked the role files and the plugin.
|
|
92
|
+
def untrack_paths(_root, tracked) = tracked.call(role_dir) + tracked.call(PLUGIN)
|
|
93
|
+
|
|
94
|
+
def launch_env = config_env(flow_file("mcp", "opencode.json"))
|
|
95
|
+
def run_env(config) = config_env(run_file(config, "mcp", "opencode.json"))
|
|
96
|
+
def config_env(file) = File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
|
|
97
|
+
|
|
98
|
+
# The TUI has no variant flag. It reads the variant from the agent file.
|
|
99
|
+
def launch(role, model, effort = nil)
|
|
100
|
+
OpencodeVariant.warn_unused(File.join(role_dir, "#{role}.md"), role, effort)
|
|
101
|
+
["opencode", ".", "--agent", role, "--prompt", "Start your work loop now.", *model_flag(model)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def effort_flag(effort) = super(effort, "--variant")
|
|
105
|
+
|
|
106
|
+
# `opencode run --format json`. The result event shape is not verified,
|
|
107
|
+
# so the log shows no result text.
|
|
108
|
+
def build_command(config, prompt, session_id)
|
|
109
|
+
%w[opencode run --format json --agent] + [config.role] + model_flag(config.model, "-m") +
|
|
110
|
+
effort_flag(config.effort) + resume(session_id, "--session") + [prompt]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# DeepSeek costs twice as much at peak hours.
|
|
114
|
+
def peak_rate?(config) = configured_model(config).to_s.include?("deepseek")
|
|
115
|
+
|
|
116
|
+
# The model of a run in the opencode order: --model, the agent file, the project config, the user config.
|
|
117
|
+
CONFIG_MODEL = /^\s*"model"\s*:\s*"([^"]+)"/
|
|
118
|
+
AGENT_MODEL = /\A---\n(?:(?!^---$).)*?^model:\s*(\S+)/m
|
|
119
|
+
|
|
120
|
+
def configured_model(config)
|
|
121
|
+
config.model || find(File.join(role_dir, "#{config.role}.md"), AGENT_MODEL) ||
|
|
122
|
+
config_paths.lazy.filter_map { |path| find(path, CONFIG_MODEL) }.first
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def config_paths
|
|
126
|
+
home = File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "opencode")
|
|
127
|
+
%w[opencode.json opencode.jsonc] + %w[opencode.jsonc opencode.json].map { |name| File.join(home, name) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def find(path, pattern) = File.exist?(path) ? File.read(path)[pattern, 1] : nil
|
|
131
|
+
|
|
132
|
+
# opencode prints terminal escape codes and can glue two JSON objects
|
|
133
|
+
# onto one line, so the session ID is matched on the start of an event
|
|
134
|
+
# object (captured from a real run), not by parsing whole lines.
|
|
135
|
+
EVENT_SESSION = /\{"type":"[\w.-]+","timestamp":\d+,"sessionID":"([^"]+)"/
|
|
136
|
+
# Each model call ends with a step_finish event. Its tokens.input
|
|
137
|
+
# excludes the cache reads and writes; reasoning is billed as output.
|
|
138
|
+
STEP_FINISH = /\{"type":"step_finish".*/
|
|
139
|
+
|
|
140
|
+
def stale_session?(output) = output.include?("Session not found")
|
|
141
|
+
def session_id(output) = output.scan(EVENT_SESSION).flatten.last
|
|
142
|
+
|
|
143
|
+
def steps(output)
|
|
144
|
+
output.scan(STEP_FINISH).filter_map { |json| Harness::Events.parse(json)&.dig("part", "tokens") }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def usage(output) = Harness::Usage.sum(steps(output).map { |tokens| Harness::Usage.normalize(flat(tokens)) })
|
|
148
|
+
def context(output) = steps(output).last&.then { |tokens| Harness::Usage.input(flat(tokens)) }
|
|
149
|
+
|
|
150
|
+
def flat(tokens)
|
|
151
|
+
{ "input_tokens" => tokens["input"].to_i, "cache_read_input_tokens" => tokens.dig("cache", "read").to_i,
|
|
152
|
+
"cache_creation_input_tokens" => tokens.dig("cache", "write").to_i,
|
|
153
|
+
"output_tokens" => tokens["output"].to_i + tokens["reasoning"].to_i }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# opencode keeps its sessions in a SQLite database. The sqlite3 CLI reads it.
|
|
157
|
+
def database = File.join(Dir.home, ".local", "share", "opencode", "opencode.db")
|
|
158
|
+
|
|
159
|
+
def transcript_calls(id)
|
|
160
|
+
return [] unless File.exist?(database) && id.match?(/\A[\w-]+\z/)
|
|
161
|
+
|
|
162
|
+
out, status = Open3.capture2("sqlite3", "-readonly", "-json", database, transcript_query(id))
|
|
163
|
+
status.success? && !out.empty? ? JSON.parse(out).map(&:values) : []
|
|
164
|
+
rescue SystemCallError, JSON::ParserError
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def transcript_query(id)
|
|
169
|
+
"select json_extract(data,'$.tool') tool, json_extract(data,'$.state.input.command') command, " \
|
|
170
|
+
"length(json_extract(data,'$.state.output')) size from part where session_id = '#{id}' " \
|
|
171
|
+
"and json_extract(data,'$.type') = 'tool'"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# OpencodeVariant tells the user how to change the level of an interactive session.
|
|
176
|
+
module OpencodeVariant
|
|
177
|
+
PATTERN = /\A---\n(?:(?!^---$).)*?^variant:\s*(\S+)/m
|
|
178
|
+
HINT = "maf: the opencode TUI cannot take --effort %<effort>s. The session starts with %<current>s. " \
|
|
179
|
+
"In the session, press ctrl+t to pick the variant %<effort>s. If the agent is in its work " \
|
|
180
|
+
"loop, stop it (Esc) and restart the loop: \"Start your work loop now.\" " \
|
|
181
|
+
"To keep the level for each session: maf add opencode:%<role>s --effort %<role>s=%<effort>s"
|
|
182
|
+
|
|
183
|
+
def self.warn_unused(path, role, effort)
|
|
184
|
+
current = File.exist?(path) ? File.read(path)[PATTERN, 1] : nil
|
|
185
|
+
return if effort.nil? || effort == current
|
|
186
|
+
|
|
187
|
+
warn format(HINT, effort: effort, role: role, current: current ? "variant #{current}" : "the default variant")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
data/lib/maf/shared/processes.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
4
|
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
5
|
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "time"
|
|
6
7
|
|
|
7
8
|
module Maf
|
|
8
9
|
module Shared
|
|
@@ -20,6 +21,25 @@ module Maf
|
|
|
20
21
|
true
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
def self.same?(pid, started)
|
|
25
|
+
!started.to_s.empty? && alive?(pid) && started_at(pid) == started.to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
LEGACY_LIVE_WINDOW = 3600
|
|
29
|
+
|
|
30
|
+
def self.live?(entry)
|
|
31
|
+
return false unless alive?(entry["pid"])
|
|
32
|
+
|
|
33
|
+
recorded = entry["started"].to_s
|
|
34
|
+
recorded.empty? ? recent?(entry["seen_at"]) : recorded == started_at(entry["pid"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.recent?(seen_at)
|
|
38
|
+
Time.now - Time.parse(seen_at.to_s) < LEGACY_LIVE_WINDOW
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
23
43
|
# The start time as ps prints it, or "" when ps fails.
|
|
24
44
|
def self.started_at(pid)
|
|
25
45
|
IO.popen(PS_ENV, ["ps", "-o", "lstart=", "-p", pid.to_s], err: File::NULL, &:read).to_s.strip
|
data/lib/maf/shared/roles.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Maf
|
|
|
14
14
|
# Hermes toolsets for a role with can_edit false: no file, code_execution,
|
|
15
15
|
# or delegation toolset. The shell stays, because the role needs coord and git.
|
|
16
16
|
READ_ONLY_TOOLSETS = "terminal,web,skills,todo,memory,session_search,clarify"
|
|
17
|
+
|
|
18
|
+
# Hermes toolsets for a role that edits. coord is the only task board and
|
|
19
|
+
# maf starts the workers, so kanban, delegation, and cronjob stay off.
|
|
20
|
+
EDIT_TOOLSETS = "#{READ_ONLY_TOOLSETS},file,code_execution,browser,vision".freeze
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
end
|
|
@@ -0,0 +1,128 @@
|
|
|
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 "fileutils"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# Bwrap runs a command in a Linux bubblewrap sandbox.
|
|
12
|
+
module Bwrap
|
|
13
|
+
COMMAND = "bwrap"
|
|
14
|
+
MARKER = "MAF_BWRAP"
|
|
15
|
+
BASE = ["--ro-bind", "/", "/", "--dev-bind", "/dev", "/dev", "--tmpfs", "/dev/shm", "--proc", "/proc",
|
|
16
|
+
"--tmpfs", "/tmp", "--setenv", MARKER, "1"].freeze
|
|
17
|
+
MASKED_DIRS = %w[/run/user /run/dbus].freeze
|
|
18
|
+
MASKED_SOCKETS = %w[/run/docker.sock /run/podman/podman.sock /run/containerd/containerd.sock].freeze
|
|
19
|
+
HOOK = "# Project hook for coord worktree. See assets/worktree-env.example.rb in the maf gem.\n"
|
|
20
|
+
|
|
21
|
+
def self.wrap(cmd, paths)
|
|
22
|
+
return cmd if inside?
|
|
23
|
+
|
|
24
|
+
[binary, *BASE, *masks, *home, *binds(paths), *protects(paths), "--die-with-parent", "--", *cmd]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.available? = !binary.nil?
|
|
28
|
+
|
|
29
|
+
def self.binary
|
|
30
|
+
dirs = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).reject { |dir| agent_dir?(File.expand_path(dir)) }
|
|
31
|
+
dirs.map { |dir| File.join(dir, COMMAND) }.find { |file| File.executable?(file) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.agent_dir?(dir)
|
|
35
|
+
writes = Array(Sandbox.policy["write"]).map { |path| File.expand_path(path, Project.root) }
|
|
36
|
+
tops = [Project.root, "/tmp", "/var/tmp", ENV.fetch("TMPDIR", nil), *writes].compact.map { _1.chomp("/") }
|
|
37
|
+
tops.any? { |top| dir == top || dir.start_with?("#{top}/") }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.require!
|
|
41
|
+
inside? || (available? && overlay?) || abort("maf: sandbox mode bwrap needs bubblewrap 0.10 or later.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.inside?(mountinfo = "/proc/self/mountinfo")
|
|
45
|
+
ENV[MARKER] == "1" && File.foreach(mountinfo).any? { |line| home_overlay?(line) }
|
|
46
|
+
rescue SystemCallError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# mountinfo writes a space in a path as \040.
|
|
51
|
+
def self.home_overlay?(line) = line.include?(" #{mount_path(Dir.home)} ") && line.include?(" - overlay ")
|
|
52
|
+
def self.mount_path(path) = path.gsub(" ", "\\\\040")
|
|
53
|
+
|
|
54
|
+
def self.masks
|
|
55
|
+
runtime = ENV.fetch("XDG_RUNTIME_DIR", "")
|
|
56
|
+
MASKED_DIRS.select { |dir| File.directory?(dir) }.flat_map { |dir| ["--tmpfs", dir] } +
|
|
57
|
+
(runtime.start_with?("/run/user/") ? ["--dir", runtime] : []) +
|
|
58
|
+
MASKED_SOCKETS.select { |path| File.exist?(path) }.flat_map { |path| ["--ro-bind", "/dev/null", path] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.home = ["--overlay-src", Dir.home, "--tmp-overlay", Dir.home]
|
|
62
|
+
def self.overlay? = IO.popen([binary, "--help"], err: %i[child out], &:read).include?("--tmp-overlay")
|
|
63
|
+
|
|
64
|
+
def self.binds(paths)
|
|
65
|
+
FileUtils.mkdir_p(paths.harness_dirs)
|
|
66
|
+
paths.prefixes.each { |file| File.exist?(file) || File.write(file, "{}\n") }
|
|
67
|
+
writable(paths).select { |path| File.exist?(path) }.flat_map { |path| ["--bind", path, path] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.writable(paths)
|
|
71
|
+
((paths.dirs - ["/tmp"]) + paths.harness_dirs + paths.prefixes).reject { |path| masked?(path) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.masked?(path)
|
|
75
|
+
(MASKED_DIRS + MASKED_SOCKETS).any? do |mask|
|
|
76
|
+
path == mask || path.start_with?("#{mask}/") || mask.start_with?("#{path.chomp("/")}/")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A mount point cannot be renamed, so each parent folder is bound onto itself.
|
|
81
|
+
# Then each protected path is bound read-only.
|
|
82
|
+
def self.protects(paths)
|
|
83
|
+
create_protected(paths)
|
|
84
|
+
parents = bound_parents(paths).select { |dir| File.directory?(dir) }.flat_map { |dir| ["--bind", dir, dir] }
|
|
85
|
+
parents + paths.protected.select { |path| File.exist?(path) }.flat_map { |path| ["--ro-bind", path, path] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.bound_parents(paths)
|
|
89
|
+
tops = writable(paths)
|
|
90
|
+
paths.parents.select { |dir| tops.any? { |top| dir == top || dir.start_with?("#{top.chomp("/")}/") } }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A bind needs an existing path. Else the agent could create a protected
|
|
94
|
+
# path, so the folders, the worktree hook, and the harness config are created first.
|
|
95
|
+
def self.create_protected(paths)
|
|
96
|
+
paths.protected_dirs.each { |dir| make(dir) }
|
|
97
|
+
create_hook(paths.coord_file("worktree-env.rb"))
|
|
98
|
+
paths.placeholders.each { |path| placeholder(path) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.create_hook(hook)
|
|
102
|
+
File.exist?(File.dirname(hook)) && !File.exist?(hook) && File.write(hook, HOOK)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.placeholder(path)
|
|
106
|
+
return if File.exist?(path) || File.symlink?(path) || !File.directory?(File.dirname(path))
|
|
107
|
+
|
|
108
|
+
name = File.basename(path)
|
|
109
|
+
File.extname(name).empty? && !name.start_with?(".") ? make(path) : write(path, empty_text(name))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.write(path, text)
|
|
113
|
+
File.write(path, text)
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.empty_text(name) = name.end_with?(".json", ".jsonc") ? "{}\n" : ""
|
|
119
|
+
|
|
120
|
+
def self.make(dir)
|
|
121
|
+
FileUtils.mkdir_p(dir)
|
|
122
|
+
rescue SystemCallError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
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 Sandbox
|
|
10
|
+
# HarnessFiles holds the state paths and the protected paths in the folders of the harnesses.
|
|
11
|
+
class HarnessFiles
|
|
12
|
+
def initialize(harnesses, real)
|
|
13
|
+
@harnesses = harnesses
|
|
14
|
+
@real = real
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dirs = @dirs ||= @real.call(@harnesses.flat_map(&:sandbox_dirs))
|
|
18
|
+
def prefixes = @real.call(@harnesses.flat_map(&:sandbox_prefixes))
|
|
19
|
+
def placeholders = @harnesses.flat_map(&:sandbox_placeholders)
|
|
20
|
+
def config = @harnesses.flat_map(&:sandbox_protected).select { |path| File.file?(path) }
|
|
21
|
+
|
|
22
|
+
def state
|
|
23
|
+
@state ||= @harnesses.flat_map(&:sandbox_state).map do |pattern|
|
|
24
|
+
pattern = File.expand_path(pattern)
|
|
25
|
+
File.join(@real.call([File.dirname(pattern)]).first, File.basename(pattern))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def state?(path)
|
|
30
|
+
dir = dirs.find { |folder| path.start_with?("#{folder}/") } or return false
|
|
31
|
+
top = File.join(dir, path.delete_prefix("#{dir}/").split("/").first)
|
|
32
|
+
state.any? { |pattern| File.fnmatch?(pattern, top, File::FNM_DOTMATCH | File::FNM_PATHNAME) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def locked
|
|
36
|
+
@locked ||= (dirs.flat_map { |dir| children(dir) }.reject { |path| state?(path) } +
|
|
37
|
+
@harnesses.flat_map(&:sandbox_protected)).uniq
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def children(dir)
|
|
43
|
+
Dir.children(dir).map { |name| File.join(dir, name) }
|
|
44
|
+
rescue SystemCallError
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
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 "fileutils"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# Links puts back a protected symlink that the agent changed in a bwrap run.
|
|
12
|
+
module Links
|
|
13
|
+
def self.record(paths) = paths.select { |path| File.symlink?(path) }.to_h { |path| [path, File.readlink(path)] }
|
|
14
|
+
|
|
15
|
+
def self.restore(links) = links.each { |link, target| restore_link(link, target) }
|
|
16
|
+
|
|
17
|
+
def self.restore_link(link, target)
|
|
18
|
+
return if File.symlink?(link) && File.readlink(link) == target
|
|
19
|
+
|
|
20
|
+
warn "maf: sandbox: the agent changed the protected link #{link}. maf put it back."
|
|
21
|
+
FileUtils.rm_rf(link) && File.symlink(target, link)
|
|
22
|
+
rescue SystemCallError => e
|
|
23
|
+
warn "maf: sandbox: cannot put back the link #{link}: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|