maf 0.1.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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +411 -0
- data/assets/agents-contract.md +80 -0
- data/assets/analyst +240 -0
- data/assets/coord +2936 -0
- data/assets/dashboard +553 -0
- data/assets/dashboard.html +341 -0
- data/assets/dispatcher +1687 -0
- data/assets/doc-graph-refresh +286 -0
- data/assets/env.sh +6 -0
- data/assets/git-hooks/post-commit +7 -0
- data/assets/git-hooks/post-merge +7 -0
- data/assets/git-hooks/pre-commit +32 -0
- data/assets/harness-hooks/board-watch-opencode.js +87 -0
- data/assets/harness-hooks/board-watch.rb +286 -0
- data/assets/harness-hooks/context-watch.rb +268 -0
- data/assets/harness-hooks/next-task-hermes.sh +48 -0
- data/assets/harness-hooks/next-task.rb +97 -0
- data/assets/harness-hooks/session-guard.rb +128 -0
- data/assets/taskrc.append +11 -0
- data/assets/vault +224 -0
- data/assets/worktree-env.example.rb +26 -0
- data/exe/maf +14 -0
- data/install.md +326 -0
- data/lib/maf/bootstrap/claude_settings.rb +55 -0
- data/lib/maf/bootstrap/dependencies.rb +37 -0
- data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
- data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
- data/lib/maf/bootstrap/graph_home.rb +62 -0
- data/lib/maf/bootstrap/hook_merger.rb +53 -0
- data/lib/maf/bootstrap/installer.rb +66 -0
- data/lib/maf/bootstrap/layout_planner.rb +18 -0
- data/lib/maf/bootstrap/marked_block.rb +44 -0
- data/lib/maf/bootstrap/memory_branch.rb +77 -0
- data/lib/maf/bootstrap/options.rb +34 -0
- data/lib/maf/bootstrap/project.rb +77 -0
- data/lib/maf/bootstrap/script_planner.rb +81 -0
- data/lib/maf/bootstrap/text_planner.rb +42 -0
- data/lib/maf/bootstrap/vault_starter.rb +41 -0
- data/lib/maf/bootstrap/writer.rb +69 -0
- data/lib/maf/bootstrap.rb +162 -0
- data/lib/maf/budget.rb +59 -0
- data/lib/maf/cli.rb +135 -0
- data/lib/maf/env_exclude.rb +23 -0
- data/lib/maf/flow/agent_links.rb +79 -0
- data/lib/maf/flow/bootstrapper.rb +36 -0
- data/lib/maf/flow/codex_hooks.rb +50 -0
- data/lib/maf/flow/generator.rb +63 -0
- data/lib/maf/flow/harness_linker.rb +37 -0
- data/lib/maf/flow/hermes_hook.rb +48 -0
- data/lib/maf/flow/hermes_hook_setup.rb +69 -0
- data/lib/maf/flow/hook_files.rb +16 -0
- data/lib/maf/flow/hook_installer.rb +33 -0
- data/lib/maf/flow/legacy_codex_hook.rb +71 -0
- data/lib/maf/flow/manifest.rb +51 -0
- data/lib/maf/flow/mcp_config.rb +72 -0
- data/lib/maf/flow/mcp_installer.rb +45 -0
- data/lib/maf/flow/models.rb +61 -0
- data/lib/maf/flow/options.rb +65 -0
- data/lib/maf/flow/prompt_builder.rb +85 -0
- data/lib/maf/flow/prompt_text.rb +263 -0
- data/lib/maf/flow/report.rb +89 -0
- data/lib/maf/flow/role_catalog.rb +40 -0
- data/lib/maf/flow/role_files.rb +72 -0
- data/lib/maf/flow/role_stub.rb +38 -0
- data/lib/maf/flow/roster.rb +28 -0
- data/lib/maf/flow/validator.rb +38 -0
- data/lib/maf/flow/workflow.rb +28 -0
- data/lib/maf/flow.rb +84 -0
- data/lib/maf/local_exclude.rb +53 -0
- data/lib/maf/menu.rb +101 -0
- data/lib/maf/migrate/moves.rb +44 -0
- data/lib/maf/migrate/rewrites.rb +53 -0
- data/lib/maf/migrate/role_files.rb +35 -0
- data/lib/maf/migrate/runner.rb +66 -0
- data/lib/maf/migrate/worktrees.rb +65 -0
- data/lib/maf/migrate.rb +62 -0
- data/lib/maf/prompt.rb +40 -0
- data/lib/maf/retire.rb +116 -0
- data/lib/maf/role_limits.rb +49 -0
- data/lib/maf/setup_agent/args.rb +57 -0
- data/lib/maf/setup_agent/dispatch.rb +44 -0
- data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
- data/lib/maf/setup_agent/hermes_skill.rb +26 -0
- data/lib/maf/setup_agent/launcher.rb +85 -0
- data/lib/maf/setup_agent/manifest.rb +35 -0
- data/lib/maf/setup_agent/project.rb +9 -0
- data/lib/maf/setup_agent/role_file.rb +30 -0
- data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
- data/lib/maf/setup_agent/worktree.rb +50 -0
- data/lib/maf/setup_agent.rb +111 -0
- data/lib/maf/shared/git_exclude.rb +33 -0
- data/lib/maf/shared/git_identity.rb +41 -0
- data/lib/maf/shared/peak_rate.rb +20 -0
- data/lib/maf/shared/processes.rb +31 -0
- data/lib/maf/shared/project.rb +34 -0
- data/lib/maf/shared/roles.rb +19 -0
- data/lib/maf/team.rb +114 -0
- data/lib/maf/team_command.rb +73 -0
- data/lib/maf/uninstall/claude_settings.rb +40 -0
- data/lib/maf/uninstall/codex_hooks.rb +18 -0
- data/lib/maf/uninstall/commit_guard.rb +16 -0
- data/lib/maf/uninstall/coordination.rb +15 -0
- data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
- data/lib/maf/uninstall/git.rb +13 -0
- data/lib/maf/uninstall/local_files.rb +33 -0
- data/lib/maf/uninstall/manifest.rb +29 -0
- data/lib/maf/uninstall/marked_files.rb +37 -0
- data/lib/maf/uninstall/mcp_entries.rb +43 -0
- data/lib/maf/uninstall/notes.rb +31 -0
- data/lib/maf/uninstall/owned.rb +12 -0
- data/lib/maf/uninstall/role_files.rb +51 -0
- data/lib/maf/uninstall/runner.rb +67 -0
- data/lib/maf/uninstall/scripts.rb +35 -0
- data/lib/maf/uninstall/vault_watcher.rb +21 -0
- data/lib/maf/uninstall/worktrees.rb +30 -0
- data/lib/maf/uninstall.rb +59 -0
- data/lib/maf/untrack.rb +90 -0
- data/lib/maf/version.rb +5 -0
- data/lib/maf/worker_archive.rb +63 -0
- data/lib/maf/worker_control.rb +137 -0
- data/lib/maf/workers.rb +37 -0
- data/lib/maf.rb +5 -0
- data/templates/claude.md.erb +16 -0
- data/templates/codex.md.erb +7 -0
- data/templates/hermes.md.erb +12 -0
- data/templates/opencode.md.erb +24 -0
- data/templates/role-stub.yml.erb +15 -0
- data/templates/roles.yml +289 -0
- data/templates/workflows/panel.md +20 -0
- data/templates/workflows/plan-review.md +9 -0
- data/templates/workflows/simple.md +4 -0
- data/templates/workflows/tdd.md +8 -0
- metadata +193 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# bootstrap.rb - install the multi-agent coordination layer into a project.
|
|
5
|
+
# flow.rb runs this file as a subprocess.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ruby lib/maf/bootstrap.rb /path/to/project [--roles architect,backend-developer,...]
|
|
9
|
+
# [--check] [--install-deps] [--force]
|
|
10
|
+
#
|
|
11
|
+
# Idempotent: every file change is marker-guarded or content-compared, so
|
|
12
|
+
# re-running never duplicates blocks and never reinstalls what is present.
|
|
13
|
+
# Ruby: 3.0+ (same as coord).
|
|
14
|
+
require "fileutils"
|
|
15
|
+
require "json"
|
|
16
|
+
require "optparse"
|
|
17
|
+
|
|
18
|
+
abort "bootstrap: Ruby 3.0+ required (current: #{RUBY_VERSION}). Install with " \
|
|
19
|
+
"mise/brew: `brew install mise && mise install ruby`." if RUBY_VERSION.split(".").first.to_i < 3
|
|
20
|
+
|
|
21
|
+
module Maf
|
|
22
|
+
module Bootstrap
|
|
23
|
+
MARKER = ">>> multi-agent-flow >>>"
|
|
24
|
+
END_MARKER = "<<< multi-agent-flow <<<"
|
|
25
|
+
# The one folder in the project that holds every file of the flow.
|
|
26
|
+
MAF_DIR = ".maf"
|
|
27
|
+
COORD_SIGNATURE = "coord - shared coordination layer"
|
|
28
|
+
# Older installs copied setup_agent into the project. uninstall.rb removes it.
|
|
29
|
+
SETUP_AGENT_SIGNATURE = "setup_agent - create a worktree for one agent and launch its harness session."
|
|
30
|
+
DISPATCHER_SIGNATURE = "dispatcher - task board and inbox monitor that starts one-shot agents."
|
|
31
|
+
VAULT_SIGNATURE = "vault - shared knowledge base watcher (graphify + Obsidian + MCP)."
|
|
32
|
+
DASHBOARD_SIGNATURE = "dashboard - local observability web UI for multi-agent coordination."
|
|
33
|
+
DASHBOARD_PAGE_SIGNATURE = "dashboard.html - page of the maf dashboard."
|
|
34
|
+
ANALYST_SIGNATURE = "analyst - ask a small model for token hints about one dispatched worker."
|
|
35
|
+
# The scripts in .maf/bin load the shared library from .maf/lib/maf/shared/.
|
|
36
|
+
# Its source is lib/maf/shared/. The installer gives paths relative to assets/.
|
|
37
|
+
SHARED_DIR = File.join("lib", "maf", "shared")
|
|
38
|
+
SHARED_SOURCE = File.join("..", SHARED_DIR)
|
|
39
|
+
SHARED_SIGNATURE = "maf shared library - code that the maf CLI and the scripts in .maf/bin share."
|
|
40
|
+
SUBDIRS = %w[inbox locks exports message-hooks harness-hooks].freeze
|
|
41
|
+
|
|
42
|
+
NEXT_TASK_HOOK_SIGNATURE = "next-task.rb - Stop hook for Claude Code and Codex."
|
|
43
|
+
NEXT_TASK_HERMES_SIGNATURE = "next-task-hermes.sh - on_session_end hook for Hermes Agent."
|
|
44
|
+
BOARD_WATCH_SIGNATURE = "board-watch.rb - background board watcher for Claude Code sessions."
|
|
45
|
+
SESSION_GUARD_SIGNATURE = "session-guard.rb - authorize hooks for a registered MAF session."
|
|
46
|
+
CONTEXT_WATCH_SIGNATURE = "context-watch.rb - status, usage, and context limit hook"
|
|
47
|
+
OPENCODE_BOARD_WATCH_SIGNATURE = "board-watch-opencode.js - opencode plugin that wakes an idle session"
|
|
48
|
+
OPENCODE_PLUGIN = File.join(".opencode", "plugins", "board-watch.js")
|
|
49
|
+
COMMIT_GUARD_SIGNATURE = "commit-guard - git pre-commit hook for the multi-agent flow."
|
|
50
|
+
DOC_GRAPH_SIGNATURE = "doc-graph-refresh - rebuild the knowledge graph after a markdown change."
|
|
51
|
+
ENV_SIGNATURE = "env.sh - shell environment of the multi-agent flow."
|
|
52
|
+
|
|
53
|
+
# Claude Code harness hooks: [event, hook]. The sync next-task hook continues
|
|
54
|
+
# a session at Stop. The asyncRewake board-watch hook wakes an idle session.
|
|
55
|
+
CLAUDE_HOOKS = [
|
|
56
|
+
["SessionStart", { "type" => "command", "command" => "ruby .maf/coordination/harness-hooks/next-task.rb" }],
|
|
57
|
+
["Stop", { "type" => "command", "command" => "ruby .maf/coordination/harness-hooks/next-task.rb" }],
|
|
58
|
+
*%w[SessionStart Stop].map do |event|
|
|
59
|
+
[event, { "type" => "command", "command" => "ruby .maf/coordination/harness-hooks/context-watch.rb" }]
|
|
60
|
+
end,
|
|
61
|
+
*%w[SessionStart Stop].map do |event|
|
|
62
|
+
[event, { "type" => "command", "command" => "ruby .maf/coordination/harness-hooks/board-watch.rb",
|
|
63
|
+
"async" => true, "asyncRewake" => true, "timeout" => 604_800 }]
|
|
64
|
+
end
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
# The knowledge graph and the Obsidian vault, at the project root where graphify looks by default.
|
|
68
|
+
GRAPH_DIR = "graphify-out"
|
|
69
|
+
|
|
70
|
+
# The paths that every install excludes from git. AgentLinks and CodexHooks
|
|
71
|
+
# add the harness paths they create. The graph entry has no trailing slash,
|
|
72
|
+
# so it also hides the graph symlink in each worktree.
|
|
73
|
+
EXCLUDED = [".maf/", "/#{GRAPH_DIR}", OPENCODE_PLUGIN].freeze
|
|
74
|
+
|
|
75
|
+
# The Claude Code settings of the flow. `maf start` passes them with --settings,
|
|
76
|
+
# so the project's .claude/settings.json stays as it is.
|
|
77
|
+
CLAUDE_SETTINGS = File.join(MAF_DIR, "claude", "settings.json")
|
|
78
|
+
|
|
79
|
+
# Maps each writing action kind to the Installer method that performs it.
|
|
80
|
+
# Every writer takes (path, source). :skip and :refuse write nothing.
|
|
81
|
+
WRITERS = { mkdir: :make_dir, touch: :touch_file, create: :write_script, update: :write_script,
|
|
82
|
+
create_taskrc: :write_taskrc, upgrade_taskrc: :upgrade_taskrc,
|
|
83
|
+
local_exclude: :write_exclude,
|
|
84
|
+
configure_claude_hook: :configure_claude_settings, merge_hook: :merge_hook }.freeze
|
|
85
|
+
|
|
86
|
+
REQUIRED_DEPS = {
|
|
87
|
+
"task" => {
|
|
88
|
+
why: "Taskwarrior, the task board backend",
|
|
89
|
+
install: -> { system("brew", "install", "task") }
|
|
90
|
+
}
|
|
91
|
+
}.freeze
|
|
92
|
+
|
|
93
|
+
OPTIONAL_DEPS = {
|
|
94
|
+
"graphify" => "knowledge graph (pip/uv install graphifyy)"
|
|
95
|
+
}.freeze
|
|
96
|
+
|
|
97
|
+
NEXT_STEPS = <<~TEXT
|
|
98
|
+
Next steps (run inside %{project}):
|
|
99
|
+
|
|
100
|
+
1. Verify:
|
|
101
|
+
cd %{project} && source .maf/env.sh && coord init && coord status
|
|
102
|
+
|
|
103
|
+
2. Set COORD_ROLE and COORD_WORKER so messages and locks are attributed, e.g.:
|
|
104
|
+
export COORD_ROLE=local COORD_WORKER=local-1
|
|
105
|
+
|
|
106
|
+
3. Shared memory (graphify + Obsidian vault): %{vault_note}
|
|
107
|
+
|
|
108
|
+
4. Serialize local generation on the shared model host:
|
|
109
|
+
coord with-lock ollama -- <command>
|
|
110
|
+
|
|
111
|
+
Requested roles: %{roles}
|
|
112
|
+
|
|
113
|
+
The task board lives in %{project}/.maf/coordination/taskdata (project-local),
|
|
114
|
+
not in your global Taskwarrior database. Point `task` at it directly with:
|
|
115
|
+
TASKRC=%{project}/.maf/coordination/taskrc task ...
|
|
116
|
+
TEXT
|
|
117
|
+
|
|
118
|
+
MIGRATION_NOTE = <<~TEXT
|
|
119
|
+
|
|
120
|
+
[multi-agent-flow] NOTE: found the multi-agent-flow UDA block in your
|
|
121
|
+
global %{taskrc}. Older versions of this installer shared one Taskwarrior
|
|
122
|
+
database across every project. This install now uses a project-local
|
|
123
|
+
database instead (%{project}/.maf/coordination/taskdata) and does not touch
|
|
124
|
+
%{taskrc}.
|
|
125
|
+
|
|
126
|
+
Tasks already in the global database are NOT moved automatically. To
|
|
127
|
+
bring old tasks into this project:
|
|
128
|
+
TASKRC=%{taskrc} task export project:%{project_name} > /tmp/old-tasks.json
|
|
129
|
+
TASKRC=%{project}/.maf/coordination/taskrc task import /tmp/old-tasks.json
|
|
130
|
+
(adjust the `project:` filter to however the old tasks are tagged.)
|
|
131
|
+
TEXT
|
|
132
|
+
|
|
133
|
+
def self.say(message)
|
|
134
|
+
puts "[multi-agent-flow] #{message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.which(bin)
|
|
138
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, bin)) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
require_relative "local_exclude"
|
|
144
|
+
require_relative "shared/git_identity"
|
|
145
|
+
require_relative "bootstrap/marked_block"
|
|
146
|
+
require_relative "bootstrap/options"
|
|
147
|
+
require_relative "bootstrap/dependencies"
|
|
148
|
+
require_relative "bootstrap/project"
|
|
149
|
+
require_relative "bootstrap/global_taskrc_warning"
|
|
150
|
+
require_relative "bootstrap/layout_planner"
|
|
151
|
+
require_relative "bootstrap/script_planner"
|
|
152
|
+
require_relative "bootstrap/git_hook_planner"
|
|
153
|
+
require_relative "bootstrap/text_planner"
|
|
154
|
+
require_relative "bootstrap/claude_settings"
|
|
155
|
+
require_relative "bootstrap/hook_merger"
|
|
156
|
+
require_relative "bootstrap/writer"
|
|
157
|
+
require_relative "bootstrap/graph_home"
|
|
158
|
+
require_relative "bootstrap/memory_branch"
|
|
159
|
+
require_relative "bootstrap/vault_starter"
|
|
160
|
+
require_relative "bootstrap/installer"
|
|
161
|
+
|
|
162
|
+
Maf::Bootstrap::Installer.new(ARGV).run if __FILE__ == $PROGRAM_NAME
|
data/lib/maf/budget.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# budget.rb - team limits for maf prepare.
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Maf
|
|
7
|
+
# Budget reads the "team" key of .maf/config.json:
|
|
8
|
+
# "team": { "max_workers": 6, "allow": ["claude", "opencode:deepseek/deepseek-v4-flash"] }
|
|
9
|
+
# max_workers does not count the project manager. An "allow" entry without
|
|
10
|
+
# a model allows every model of that harness. Without "team", maf sets no limits.
|
|
11
|
+
class Budget
|
|
12
|
+
LEAD = "project-manager"
|
|
13
|
+
|
|
14
|
+
def self.at(root)
|
|
15
|
+
path = File.join(root, ".maf/config.json")
|
|
16
|
+
new(team(path))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A corrupt manifest must not silently drop the budget: without limits,
|
|
20
|
+
# maf would start every worker. Stop with a clear message instead.
|
|
21
|
+
def self.team(path)
|
|
22
|
+
return {} unless File.exist?(path)
|
|
23
|
+
|
|
24
|
+
JSON.parse(File.read(path)).fetch("team", {})
|
|
25
|
+
rescue JSON::ParserError
|
|
26
|
+
abort "maf: #{path} is not valid JSON. Fix it, then run this command again."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(team)
|
|
30
|
+
@max = team["max_workers"]
|
|
31
|
+
@allow = team.fetch("allow", [])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# With exactly one allowed model for the harness, maf uses that model.
|
|
35
|
+
def default_model(harness)
|
|
36
|
+
models = @allow.map { |spec| spec.split(":", 2) }.select { |name, _| name == harness }.map(&:last)
|
|
37
|
+
models.size == 1 ? models.first : nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check!(harness, model, count)
|
|
41
|
+
abort "maf: #{[harness, model].compact.join(":")} is not allowed. Allowed: #{@allow.join(", ")}" \
|
|
42
|
+
unless allowed?(harness, model)
|
|
43
|
+
abort "maf: the team would have #{count} workers. max_workers is #{@max}. Retire a worker first." \
|
|
44
|
+
if @max && count > @max
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def allowed?(harness, model)
|
|
48
|
+
@allow.empty? || @allow.any? { |spec| [harness, "#{harness}:#{model}"].include?(spec) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def count(workers) = workers.count { |_id, entry| entry["role"] != LEAD }
|
|
52
|
+
|
|
53
|
+
def summary
|
|
54
|
+
max = @max ? "max_workers #{@max}" : "no worker limit"
|
|
55
|
+
allow = @allow.empty? ? "every harness" : @allow.join(", ")
|
|
56
|
+
"Budget: #{max}. Allowed: #{allow}. The project manager does not count."
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/maf/cli.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Maf::CLI maps maf subcommands to the installer code. The project is always
|
|
4
|
+
# the current directory.
|
|
5
|
+
require "json"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require_relative "flow"
|
|
8
|
+
require_relative "uninstall"
|
|
9
|
+
require_relative "migrate"
|
|
10
|
+
require_relative "setup_agent"
|
|
11
|
+
require_relative "menu"
|
|
12
|
+
require_relative "team"
|
|
13
|
+
require_relative "team_command"
|
|
14
|
+
require_relative "worker_control"
|
|
15
|
+
require_relative "untrack"
|
|
16
|
+
require_relative "version"
|
|
17
|
+
|
|
18
|
+
module Maf
|
|
19
|
+
MANIFEST = ".maf/config.json"
|
|
20
|
+
|
|
21
|
+
def self.flow(*args) = Flow::Generator.new(["--project", Dir.pwd, *args]).run
|
|
22
|
+
def self.role_names = Flow::RoleCatalog.new(Dir.pwd).roles.keys
|
|
23
|
+
|
|
24
|
+
def self.agents = File.exist?(MANIFEST) ? JSON.parse(File.read(MANIFEST)).fetch("agents") : []
|
|
25
|
+
def self.agent_specs = agents.map { |a| "#{a["harness"]}:#{a["role"]}" }
|
|
26
|
+
|
|
27
|
+
# AgentArgs turns the bare HARNESS:ROLE arguments of add and remove into
|
|
28
|
+
# flow.rb flags. Flag values (for example --model ROLE=MODEL) stay as they are.
|
|
29
|
+
module AgentArgs
|
|
30
|
+
def self.convert(args, flag)
|
|
31
|
+
args.each_with_index.flat_map { |arg, i| spec?(arg, i.zero? ? nil : args[i - 1]) ? [flag, arg] : [arg] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.spec?(arg, before) = !arg.start_with?("-") && !value_flags.include?(before)
|
|
35
|
+
def self.value_flags = @value_flags ||= Flow::Generator.value_flags
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class CLI
|
|
39
|
+
COMMANDS = {
|
|
40
|
+
"add" => "[HARNESS:ROLE[:MODEL] ...] [--model ROLE=MODEL] add agents and install the flow; " \
|
|
41
|
+
"without arguments it asks",
|
|
42
|
+
"remove" => "HARNESS:ROLE ... remove agents",
|
|
43
|
+
"update" => " regenerate the files of the current agents",
|
|
44
|
+
"agents" => " list the current agents",
|
|
45
|
+
"roles" => " list the built-in roles and the project roles",
|
|
46
|
+
"role" => "add NAME add a stub role to .maf/roles.yml",
|
|
47
|
+
"start" => "[HARNESS ROLE[_WORKER]] [--dispatch [--detach]] start one agent in its worktree",
|
|
48
|
+
"prepare" => "HARNESS ROLE[_WORKER] [--dispatch|--interactive] [--replace W] prepare a worker; " \
|
|
49
|
+
"--dispatch starts it (default for the architect)",
|
|
50
|
+
"retire" => "ROLE[_WORKER] remove a worker; its tasks return to the pool",
|
|
51
|
+
"worker" => "status|stop|start|restart ROLE[_WORKER] [--force] [--max-context N] [--max-session-runs N] " \
|
|
52
|
+
"[--cache-window S] control one worker; start and restart save the session limits of the role",
|
|
53
|
+
"team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
|
|
54
|
+
"uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
|
|
55
|
+
"migrate" => "[--check] [--yes] move an old-layout install into .maf/",
|
|
56
|
+
"untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
|
|
57
|
+
"menu" => " interactive mode (also: maf without a command)",
|
|
58
|
+
"guide" => " print the setup guide for an AI agent (install.md)",
|
|
59
|
+
"version" => " print the maf version (also: --version)"
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
def initialize(argv)
|
|
63
|
+
@command, *@args = argv
|
|
64
|
+
@command = "version" if %w[-v --version].include?(@command)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run
|
|
68
|
+
return run_menu if @command.nil? && $stdin.tty?
|
|
69
|
+
return help if @command.nil? || %w[help -h --help].include?(@command)
|
|
70
|
+
abort "maf: unknown command '#{@command}'. Run: maf help" unless COMMANDS.key?(@command)
|
|
71
|
+
abort "maf: #{Migrate::HINT}" if old_layout?
|
|
72
|
+
|
|
73
|
+
send("run_#{@command}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Every command except these needs the new layout.
|
|
79
|
+
# maf uninstall --check previews the migration on an old layout.
|
|
80
|
+
def old_layout?
|
|
81
|
+
return false if %w[migrate roles menu guide version].include?(@command) || uninstall_check?
|
|
82
|
+
|
|
83
|
+
Migrate.old_layout?(Dir.pwd)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def uninstall_check? = @command == "uninstall" && @args.include?("--check")
|
|
87
|
+
|
|
88
|
+
def help
|
|
89
|
+
puts "Usage: maf COMMAND [ARGS] (run in the project root)", ""
|
|
90
|
+
COMMANDS.each { |name, text| puts " maf #{name.ljust(10)}#{text}" }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Without arguments on a terminal, maf add asks for the harness, the roles, and the models.
|
|
94
|
+
def run_add = @args.empty? && $stdin.tty? ? Menu.new.add : Maf.flow(*AgentArgs.convert(@args, "--agent"))
|
|
95
|
+
def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
|
|
96
|
+
def run_update = Maf.flow(*@args)
|
|
97
|
+
def run_roles = Maf.flow("--list-roles")
|
|
98
|
+
|
|
99
|
+
def run_role
|
|
100
|
+
abort "usage: maf role add NAME" unless @args.first == "add" && @args[1]
|
|
101
|
+
|
|
102
|
+
added = Flow::RoleStub.new(Dir.pwd, @args[1]).add == :create
|
|
103
|
+
puts(added ? "Role added: #{Flow::RoleCatalog::FILE}. Fill in the TODO lines." : "Role exists: #{@args[1]}")
|
|
104
|
+
end
|
|
105
|
+
def run_start = @args.empty? ? SetupAgent.run_here : SetupAgent.run(@args)
|
|
106
|
+
def run_prepare = Prepare.new(@args).run
|
|
107
|
+
def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
|
|
108
|
+
def run_worker
|
|
109
|
+
spec = @args[1] || abort(worker_usage)
|
|
110
|
+
limits = RoleLimits.parse(@args)
|
|
111
|
+
WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
|
|
112
|
+
.run(@args[0].to_s)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def worker_usage = "usage: maf worker #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
|
|
116
|
+
"[--max-context N] [--max-session-runs N] [--cache-window S]"
|
|
117
|
+
def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
|
|
118
|
+
def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
119
|
+
def run_menu = Menu.new.run
|
|
120
|
+
def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
|
|
121
|
+
def run_version = puts("maf #{VERSION}")
|
|
122
|
+
def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
|
|
123
|
+
def run_migrate = Migrate::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
124
|
+
|
|
125
|
+
def run_agents
|
|
126
|
+
return puts("No agents yet. Add one: maf add HARNESS:ROLE") if Maf.agents.empty?
|
|
127
|
+
|
|
128
|
+
Maf.agents.each { |a| puts agent_line(a) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def agent_line(agent)
|
|
132
|
+
"#{agent["harness"]}:#{agent["role"]}".ljust(32) + agent["model"].to_s
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "shared/git_exclude"
|
|
5
|
+
|
|
6
|
+
module Maf
|
|
7
|
+
# EnvExclude hides the .maf/env.sh of a worktree from git.
|
|
8
|
+
# .maf/env.sh holds absolute host paths and must never be committed.
|
|
9
|
+
# Git has no per-worktree exclude, so add the path to the shared local exclude.
|
|
10
|
+
# coord worktree does the same (see Coord::Worktree#exclude_env_file).
|
|
11
|
+
# An excluded file does not block git worktree remove.
|
|
12
|
+
module EnvExclude
|
|
13
|
+
FILE = ".maf/env.sh"
|
|
14
|
+
|
|
15
|
+
def self.add(dir)
|
|
16
|
+
path = Maf::Shared::GitExclude.path(dir)
|
|
17
|
+
return if path.nil? || (File.exist?(path) && File.read(path).lines.map(&:strip).include?(FILE))
|
|
18
|
+
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
20
|
+
File.open(path, "a") { |file| file.puts(FILE) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# AgentLinks makes the folder of each harness a relative symlink into
|
|
6
|
+
# .maf/agents/<harness>/. The harness reads its role files there. Each
|
|
7
|
+
# link goes into the local git exclude: the flow is not a part of the project.
|
|
8
|
+
class AgentLinks
|
|
9
|
+
def initialize(project)
|
|
10
|
+
@project = project
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns :create, :skip, or :refuse. :refuse means the folder holds files
|
|
14
|
+
# that the flow does not own, or a symlink to another place.
|
|
15
|
+
def link(harness)
|
|
16
|
+
path = File.join(@project, HARNESS_DIRS.fetch(harness))
|
|
17
|
+
return exclude(path) && :skip if linked?(path, harness)
|
|
18
|
+
return :refuse unless free?(path)
|
|
19
|
+
|
|
20
|
+
make(path, harness)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.target(harness)
|
|
24
|
+
File.join("..", MAF_DIR, "agents", harness)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A harness folder with the user's own files stays. Each role file gets a
|
|
28
|
+
# relative symlink in it instead, so the harness still finds the role.
|
|
29
|
+
# A user file with the same name stays, and its name is returned.
|
|
30
|
+
def link_files(harness)
|
|
31
|
+
dir = File.join(@project, HARNESS_DIRS.fetch(harness))
|
|
32
|
+
prune(dir, harness)
|
|
33
|
+
role_files(harness).reject { |source| link_file(dir, source) }.map { |source| File.basename(source) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.file_target(harness, name) = File.join("..", "..", MAF_DIR, "agents", harness, name)
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def linked?(path, harness)
|
|
41
|
+
File.symlink?(path) && File.readlink(path) == self.class.target(harness)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def free?(path)
|
|
45
|
+
return true unless File.exist?(path) || File.symlink?(path)
|
|
46
|
+
|
|
47
|
+
File.directory?(path) && !File.symlink?(path) && Dir.empty?(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def role_files(harness) = Dir.glob(File.join(@project, MAF_DIR, "agents", harness, "*.md")).sort
|
|
51
|
+
|
|
52
|
+
def link_file(dir, source)
|
|
53
|
+
path = File.join(dir, File.basename(source))
|
|
54
|
+
target = self.class.file_target(File.basename(File.dirname(source)), File.basename(source))
|
|
55
|
+
# A link of an older install is in git status until it is excluded.
|
|
56
|
+
return File.readlink(path) == target && exclude(path) if File.symlink?(path)
|
|
57
|
+
|
|
58
|
+
!File.exist?(path) && File.symlink(target, path) && exclude(path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def exclude(path) = LocalExclude.add(@project, path.delete_prefix("#{@project}/")) || true
|
|
62
|
+
|
|
63
|
+
# A link of a removed role points nowhere. Remove it.
|
|
64
|
+
def prune(dir, harness)
|
|
65
|
+
prefix = File.join("..", "..", MAF_DIR, "agents", harness, "")
|
|
66
|
+
Dir.glob(File.join(dir, "*.md")).each do |path|
|
|
67
|
+
File.delete(path) if File.symlink?(path) && File.readlink(path).start_with?(prefix) && !File.exist?(path)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def make(path, harness)
|
|
72
|
+
Dir.rmdir(path) if File.directory?(path)
|
|
73
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
74
|
+
File.symlink(self.class.target(harness), path)
|
|
75
|
+
exclude(path) && :create
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# Bootstrapper runs bootstrap.rb, which installs the board, the scripts,
|
|
6
|
+
# and the hooks in the project.
|
|
7
|
+
class Bootstrapper
|
|
8
|
+
def initialize(options)
|
|
9
|
+
@options = options
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
return if !@options.bootstrap? || @options.check?
|
|
14
|
+
|
|
15
|
+
make_harness_dirs
|
|
16
|
+
abort "flow: coordination bootstrap failed" unless system(*command)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Bootstrap adds the Claude Code hooks only if .claude/ exists, and the
|
|
22
|
+
# opencode plugin only if .opencode/ exists. Flow writes the role files
|
|
23
|
+
# after bootstrap, so create these dirs first.
|
|
24
|
+
def make_harness_dirs
|
|
25
|
+
used = %w[claude opencode] & @options.agents.map { |a| a[:harness] }
|
|
26
|
+
used.each { |harness| FileUtils.mkdir_p(File.join(@options.project, ".#{harness}")) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def command
|
|
30
|
+
roles = @options.agents.map { |a| a[:role] }.uniq.join(",")
|
|
31
|
+
args = [RbConfig.ruby, File.join(__dir__, "..", "bootstrap.rb"), @options.project, "--roles", roles]
|
|
32
|
+
@options.force? ? args << "--force" : args
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# Install hooks in the project. Preserve other hook definitions.
|
|
6
|
+
class CodexHooks
|
|
7
|
+
HOOKS_DIR = '"$(git rev-parse --show-toplevel)/.maf/coordination/harness-hooks'
|
|
8
|
+
COMMANDS = %w[next-task.rb context-watch.rb].map { |name| %(ruby #{HOOKS_DIR}/#{name}") }.freeze
|
|
9
|
+
EVENTS = %w[SessionStart Stop].freeze
|
|
10
|
+
FILE = ".codex/hooks.json"
|
|
11
|
+
TIMEOUT = { "timeout" => 3600 }.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(project)
|
|
14
|
+
@project = project
|
|
15
|
+
@path = File.join(project, FILE)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def install
|
|
19
|
+
data = File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
20
|
+
EVENTS.product(COMMANDS).each { |event, command| add(data, event, command) }
|
|
21
|
+
save(data)
|
|
22
|
+
LocalExclude.add(@project, FILE) unless tracked?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def save(data)
|
|
28
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
29
|
+
File.write(@path, JSON.pretty_generate(data))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A project that tracks its own Codex hooks keeps the file in git.
|
|
33
|
+
def tracked?
|
|
34
|
+
system("git", "-C", @project, "ls-files", "--error-unmatch", FILE, out: File::NULL, err: File::NULL)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def add(data, event, command)
|
|
38
|
+
entries = (data["hooks"] ||= {})[event] ||= []
|
|
39
|
+
found = entries.flat_map { |entry| entry.fetch("hooks", []) }.find { |hook| hook["command"] == command }
|
|
40
|
+
found ? found.merge!(hook(command)) : entries << { "hooks" => [hook(command)] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# After `coord await`, next-task.rb waits for work for up to 55 minutes.
|
|
44
|
+
# Without a timeout, Codex could stop the hook before work arrives.
|
|
45
|
+
def hook(command)
|
|
46
|
+
{ "type" => "command", "command" => command }.merge(command.include?("next-task.rb") ? TIMEOUT : {})
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# Generator runs one maf add, remove, update, or roles command.
|
|
6
|
+
class Generator
|
|
7
|
+
# Flags that take a value. maf uses them to tell flag values from agent specs.
|
|
8
|
+
def self.value_flags = Options.new([]).value_flags
|
|
9
|
+
|
|
10
|
+
def initialize(argv)
|
|
11
|
+
@options = Options.new(argv)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
prepare
|
|
16
|
+
return print_roles if @options.list_roles
|
|
17
|
+
|
|
18
|
+
Validator.new(@options, @roles).run
|
|
19
|
+
@options.agents.empty? ? finish_without_agents : generate_all
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def prepare
|
|
25
|
+
@options.parse
|
|
26
|
+
Validator.project!(@options)
|
|
27
|
+
@catalog = RoleCatalog.new(@options.project)
|
|
28
|
+
@roles = @catalog.roles
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def generate_all
|
|
32
|
+
Bootstrapper.new(@options).run
|
|
33
|
+
results = RoleFiles.new(@options, @roles).generate
|
|
34
|
+
HarnessLinker.new(@options).run
|
|
35
|
+
McpInstaller.new(@options).install
|
|
36
|
+
finish(results)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def finish(results)
|
|
40
|
+
pending = @options.check? ? false : HookInstaller.new(@options.agents, @options.project).install
|
|
41
|
+
manifest.write
|
|
42
|
+
Report.new(@options.project, @roles, pending).print(results)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def manifest = Manifest.new(@options, @roles)
|
|
46
|
+
|
|
47
|
+
def print_roles
|
|
48
|
+
puts "Available roles (model_hint is a recommendation only):"
|
|
49
|
+
@roles.each { |key, data| print_role(key, data) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def print_role(key, data)
|
|
53
|
+
puts "", " #{key} (#{@catalog.source(key)})", " #{data.fetch("description")}",
|
|
54
|
+
" model hint: #{data.fetch("model_hint")}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def finish_without_agents
|
|
58
|
+
manifest.write
|
|
59
|
+
puts "No agents left. Add one: maf add HARNESS:ROLE. Remove the flow: maf uninstall."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Flow
|
|
5
|
+
# HarnessLinker points the folder of each harness at the role files in
|
|
6
|
+
# .maf/agents/. A folder with the user's own files gets one link per role.
|
|
7
|
+
class HarnessLinker
|
|
8
|
+
def initialize(options)
|
|
9
|
+
@options = options
|
|
10
|
+
@links = AgentLinks.new(options.project)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
return if @options.check?
|
|
15
|
+
|
|
16
|
+
harnesses.each { |harness| link_role_files(harness) if @links.link(harness) == :refuse }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def harnesses = @options.agents.map { |a| a[:harness] }.uniq.select { |h| HARNESS_DIRS.key?(h) }
|
|
22
|
+
def dir(harness) = HARNESS_DIRS.fetch(harness)
|
|
23
|
+
|
|
24
|
+
def link_role_files(harness)
|
|
25
|
+
return warn_not_linked(harness) if File.symlink?(File.join(@options.project, dir(harness)))
|
|
26
|
+
|
|
27
|
+
kept = @links.link_files(harness)
|
|
28
|
+
warn "flow: #{dir(harness)} has own files named #{kept.join(", ")}. Those roles are not linked." if kept.any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def warn_not_linked(harness)
|
|
32
|
+
warn "flow: #{dir(harness)} points to another folder. " \
|
|
33
|
+
"The #{harness} harness cannot read the role files. Run: maf migrate"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|