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,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# setup_agent.rb - create a worktree for one agent and launch its harness session.
|
|
4
|
+
#
|
|
5
|
+
# Usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL]
|
|
6
|
+
# [--dispatch [DISPATCHER FLAGS...]]
|
|
7
|
+
# maf start claude backend-developer_1
|
|
8
|
+
# maf start opencode reviewer --model openrouter/deepseek-v3
|
|
9
|
+
# maf start hermes tester --dispatch --model openrouter/deepseek-v3
|
|
10
|
+
# maf start claude reviewer --dispatch --cache-window 1500
|
|
11
|
+
#
|
|
12
|
+
# Without --dispatch, the harness starts as an interactive session. With
|
|
13
|
+
# --dispatch, the worktree runs dispatcher instead: the agent starts only
|
|
14
|
+
# when there is work and exits when the work is done. Other flags after the
|
|
15
|
+
# positional arguments go to the dispatcher (for example --interval,
|
|
16
|
+
# --timeout, --cache-window). WORKER defaults to 1, or to "bot" with
|
|
17
|
+
# --dispatch, so a dispatched and an interactive instance of one role get
|
|
18
|
+
# separate worktrees. --detach (only with --dispatch) starts the dispatcher
|
|
19
|
+
# in the background, logs to .maf/coordination/sessions/<worker>.log, and records
|
|
20
|
+
# its pid in .maf/coordination/workers.json. `maf retire` stops it.
|
|
21
|
+
#
|
|
22
|
+
# HARNESS:ROLE must already exist in .maf/config.json (maf add HARNESS:ROLE
|
|
23
|
+
# adds one). Run from the project root.
|
|
24
|
+
|
|
25
|
+
require "json"
|
|
26
|
+
require "rbconfig"
|
|
27
|
+
require "fileutils"
|
|
28
|
+
require_relative "flow"
|
|
29
|
+
require_relative "workers"
|
|
30
|
+
require_relative "shared/git_identity"
|
|
31
|
+
|
|
32
|
+
abort "setup_agent: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
|
|
33
|
+
|
|
34
|
+
module Maf
|
|
35
|
+
module SetupAgent
|
|
36
|
+
MANIFEST = ".maf/config.json"
|
|
37
|
+
|
|
38
|
+
def self.run(argv)
|
|
39
|
+
args = Args.parse(argv)
|
|
40
|
+
manifest = Manifest.load(MANIFEST).tap { |m| m.verify!(args.harness, args.role) }
|
|
41
|
+
worktree = enter_worktree(args)
|
|
42
|
+
register(args, manifest.model_for(args.harness, args.role))
|
|
43
|
+
launch(args, manifest, worktree)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `maf start` without arguments, inside a worktree that `maf prepare` made.
|
|
47
|
+
def self.run_here
|
|
48
|
+
root = Project.root
|
|
49
|
+
entry = Maf::Workers.at(root).find(File.basename(Dir.pwd))
|
|
50
|
+
abort "maf: #{Dir.pwd} is not a prepared worktree. Run: maf start HARNESS ROLE[_WORKER]" unless entry
|
|
51
|
+
|
|
52
|
+
Dir.chdir(root)
|
|
53
|
+
run(start_args(entry))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.start_args(entry)
|
|
57
|
+
args = [entry["harness"], "#{entry["role"]}_#{entry["worker_id"]}"]
|
|
58
|
+
args += ["--model", entry["model"]] if entry["model"]
|
|
59
|
+
entry["dispatch"] ? args + ["--dispatch"] : args
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.register(args, saved_model)
|
|
63
|
+
entry = { "role" => args.role, "worker_id" => args.worker_id, "harness" => args.harness,
|
|
64
|
+
"model" => args.model || saved_model, "dispatch" => args.dispatch, "dir" => Dir.pwd }
|
|
65
|
+
Maf::Workers.at(Project.root).add(args.worker, entry.compact)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.enter_worktree(args)
|
|
69
|
+
worktree = prepare_worktree(args)
|
|
70
|
+
Dir.chdir(worktree.dir)
|
|
71
|
+
worktree.export_env!
|
|
72
|
+
worktree
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.prepare_worktree(args)
|
|
76
|
+
worktree = Worktree.ensure(args.role, args.worker_id, args.harness)
|
|
77
|
+
RoleFile.copy(Dir.pwd, worktree.dir, args.harness, args.role)
|
|
78
|
+
RuntimeHooks.install(worktree.dir, args.harness)
|
|
79
|
+
worktree
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.launch(args, manifest, _worktree)
|
|
83
|
+
identify(args)
|
|
84
|
+
model = args.model || manifest.model_for(args.harness, args.role)
|
|
85
|
+
return Dispatch.launch(args, model) if args.dispatch
|
|
86
|
+
|
|
87
|
+
MafSession.register(Project.root, args.harness)
|
|
88
|
+
Launcher.for(args.harness).launch(args.role, args.worker, model)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The session and the dispatcher pass the environment to each child, so
|
|
92
|
+
# each commit of the agent uses the git persona of the agents.
|
|
93
|
+
def self.identify(args)
|
|
94
|
+
ENV["COORD_ROLE"] = args.role
|
|
95
|
+
ENV["COORD_WORKER"] = args.worker
|
|
96
|
+
Maf::Shared::GitIdentity.apply(Project.manifest)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
require_relative "setup_agent/args"
|
|
102
|
+
require_relative "setup_agent/dispatch"
|
|
103
|
+
require_relative "setup_agent/role_file"
|
|
104
|
+
require_relative "setup_agent/manifest"
|
|
105
|
+
require_relative "setup_agent/worktree"
|
|
106
|
+
require_relative "setup_agent/launcher"
|
|
107
|
+
require_relative "setup_agent/hermes_launcher"
|
|
108
|
+
require_relative "setup_agent/project"
|
|
109
|
+
require_relative "setup_agent/hermes_skill"
|
|
110
|
+
require_relative "setup_agent/runtime_hooks"
|
|
111
|
+
require_relative "../../assets/harness-hooks/session-guard"
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
require "open3"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
module Shared
|
|
11
|
+
# GitExclude finds the info/exclude file of a clone. Every worktree of the
|
|
12
|
+
# repository shares this file. The path is nil outside a git repository.
|
|
13
|
+
module GitExclude
|
|
14
|
+
NOT_FOUND = "maf: no git exclude file for %s. Git can show the flow files as untracked."
|
|
15
|
+
|
|
16
|
+
def self.path(dir)
|
|
17
|
+
out = git_path(dir)
|
|
18
|
+
return File.expand_path(out, dir) unless out.empty?
|
|
19
|
+
|
|
20
|
+
warn format(NOT_FOUND, dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --path-format=absolute needs git 2.31. Without it, git can give a path
|
|
24
|
+
# relative to dir, so the caller makes it absolute.
|
|
25
|
+
def self.git_path(dir)
|
|
26
|
+
out, status = Open3.capture2("git", "-C", dir, "rev-parse", "--git-path", "info/exclude", err: File::NULL)
|
|
27
|
+
status.success? ? out.strip : ""
|
|
28
|
+
rescue Errno::ENOENT
|
|
29
|
+
""
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
module Shared
|
|
11
|
+
# GitIdentity is the git persona of the agents: the author and committer
|
|
12
|
+
# of each commit that an agent or the flow makes. The key git_identity
|
|
13
|
+
# ({"name", "email"}) of .maf/config.json sets it. Without that key, the
|
|
14
|
+
# bot_user and bot_email of the github section count. Without both, git
|
|
15
|
+
# uses its own config, as for a commit of the user.
|
|
16
|
+
module GitIdentity
|
|
17
|
+
KEYS = %w[GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL].freeze
|
|
18
|
+
|
|
19
|
+
def self.pair(config)
|
|
20
|
+
own = config["git_identity"] || {}
|
|
21
|
+
bot = config.fetch("github", {})
|
|
22
|
+
pair = own.empty? ? bot.values_at("bot_user", "bot_email") : own.values_at("name", "email")
|
|
23
|
+
pair unless pair.any? { |value| value.to_s.empty? }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The environment variables of git win over every git config file.
|
|
27
|
+
def self.env(config)
|
|
28
|
+
name, email = pair(config)
|
|
29
|
+
name ? KEYS.zip([name, email, name, email]).to_h : {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.apply(config) = ENV.update(env(config))
|
|
33
|
+
|
|
34
|
+
def self.read(path)
|
|
35
|
+
File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
# PeakRate knows the DeepSeek peak hours: 01:00-04:00 and 06:00-10:00 UTC,
|
|
10
|
+
# Monday to Friday. The off-peak rate is half the peak rate. Chinese public
|
|
11
|
+
# holidays are off-peak in full, but maf does not know them.
|
|
12
|
+
module PeakRate
|
|
13
|
+
HOURS = [1...4, 6...10].freeze
|
|
14
|
+
|
|
15
|
+
# The peak hours that cover TIME (UTC), or nil off-peak.
|
|
16
|
+
def self.hours(time) = time.wday.between?(1, 5) ? HOURS.find { |range| range.cover?(time.hour) } : nil
|
|
17
|
+
def self.peak?(time) = !hours(time).nil?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
# Processes tells if a pid runs and when it started. A presence record
|
|
10
|
+
# holds the start time, so every reader must get it the same way.
|
|
11
|
+
module Processes
|
|
12
|
+
PS_ENV = { "TZ" => "UTC", "LC_ALL" => "C" }.freeze
|
|
13
|
+
|
|
14
|
+
# EPERM means the process exists but belongs to another user.
|
|
15
|
+
def self.alive?(pid)
|
|
16
|
+
pid.to_i.positive? && Process.kill(0, pid.to_i) && true
|
|
17
|
+
rescue Errno::ESRCH
|
|
18
|
+
false
|
|
19
|
+
rescue Errno::EPERM
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The start time as ps prints it, or "" when ps fails.
|
|
24
|
+
def self.started_at(pid)
|
|
25
|
+
IO.popen(PS_ENV, ["ps", "-o", "lstart=", "-p", pid.to_s], err: File::NULL, &:read).to_s.strip
|
|
26
|
+
rescue SystemCallError
|
|
27
|
+
""
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
module Shared
|
|
11
|
+
# Project finds the main checkout from any worktree. `git rev-parse
|
|
12
|
+
# --git-common-dir` returns the main repo's .git dir (relative at the root,
|
|
13
|
+
# absolute in a worktree), so its parent is the main checkout.
|
|
14
|
+
module Project
|
|
15
|
+
MANIFEST = ".maf/config.json"
|
|
16
|
+
# All worktrees live inside the project, so agents need no access outside it.
|
|
17
|
+
WORKTREES_DIR = ".maf/worktrees"
|
|
18
|
+
|
|
19
|
+
def self.root
|
|
20
|
+
common = IO.popen(%w[git rev-parse --git-common-dir], err: File::NULL, &:read).to_s.strip
|
|
21
|
+
common.empty? ? Dir.pwd : File.dirname(File.expand_path(common))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.manifest
|
|
25
|
+
JSON.parse(File.read(File.join(root, MANIFEST)))
|
|
26
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# coord creates the worktree of a worker here, and maf start finds it here.
|
|
31
|
+
def self.worktree_dir(root, slug) = File.join(root, WORKTREES_DIR, slug)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
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 Roles
|
|
10
|
+
# Lead roles dispatch or coordinate instead of implementing. They own no
|
|
11
|
+
# tasks: they get an inbox prompt and no task polling.
|
|
12
|
+
LEADS = %w[project-manager architect].freeze
|
|
13
|
+
|
|
14
|
+
# Hermes toolsets for a role with can_edit false: no file, code_execution,
|
|
15
|
+
# or delegation toolset. The shell stays, because the role needs coord and git.
|
|
16
|
+
READ_ONLY_TOOLSETS = "terminal,web,skills,todo,memory,session_search,clarify"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/maf/team.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# team.rb - add, replace, and retire workers in one command.
|
|
4
|
+
#
|
|
5
|
+
# maf prepare HARNESS ROLE[_WORKER] [--model M] [--dispatch | --interactive] [--replace WORKER]
|
|
6
|
+
# maf retire WORKER
|
|
7
|
+
#
|
|
8
|
+
# The project manager runs these commands. `maf prepare` adds the role file,
|
|
9
|
+
# creates the worktree, and registers the worker. With --dispatch, it also
|
|
10
|
+
# starts the dispatcher in the background. Without --dispatch, the user runs
|
|
11
|
+
# two commands: `cd <worktree>` and `maf start`. The architect is dispatched
|
|
12
|
+
# unless --interactive is given.
|
|
13
|
+
require "rbconfig"
|
|
14
|
+
require_relative "setup_agent"
|
|
15
|
+
require_relative "workers"
|
|
16
|
+
require_relative "retire"
|
|
17
|
+
require_relative "budget"
|
|
18
|
+
|
|
19
|
+
module Maf
|
|
20
|
+
module Team
|
|
21
|
+
def self.coord(root, *args, env: {})
|
|
22
|
+
command = [RbConfig.ruby, File.join(root, ".maf", "bin", "coord"), *args]
|
|
23
|
+
IO.popen(env, command, chdir: root, err: File::NULL, &:read).to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.notify(root, text) = coord(root, "msg", "--from", "maf", "architect", text)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Prepare makes a worker ready to start. With --replace, it retires the
|
|
30
|
+
# old worker, so the old worker's tasks return to the pool. If the old and
|
|
31
|
+
# the new worker have the same id (only the harness or the model changes),
|
|
32
|
+
# the worker keeps its worktree and its claims. A new harness archives the
|
|
33
|
+
# session and the token data of the old one.
|
|
34
|
+
class Prepare
|
|
35
|
+
# The architect never talks to the user. A dispatched architect gets fresh
|
|
36
|
+
# sessions with a handoff note, so its context stays small.
|
|
37
|
+
DISPATCH_DEFAULT = %w[architect].freeze
|
|
38
|
+
|
|
39
|
+
def self.mode_args(argv)
|
|
40
|
+
return argv - ["--interactive"] if argv.include?("--interactive")
|
|
41
|
+
|
|
42
|
+
role = SetupAgent::Args.split_role(argv[1].to_s).first
|
|
43
|
+
DISPATCH_DEFAULT.include?(role) && !argv.include?("--dispatch") ? argv + ["--dispatch"] : argv
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(argv)
|
|
47
|
+
@replace = take_replace(argv)
|
|
48
|
+
@args = SetupAgent::Args.parse(self.class.mode_args(argv))
|
|
49
|
+
@root = SetupAgent::Project.root
|
|
50
|
+
@budget = Budget.at(@root)
|
|
51
|
+
@args.model ||= @budget.default_model(@args.harness)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run
|
|
55
|
+
@budget.check!(@args.harness, @args.model, @budget.count(workers_after))
|
|
56
|
+
retire = (Retire.new(@root, @replace).tap(&:check!) if @replace)
|
|
57
|
+
Dir.chdir(@root) { prepare }
|
|
58
|
+
retire.run if retire && Workers.id(@replace) != @args.worker
|
|
59
|
+
announce
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def announce
|
|
63
|
+
Team.notify(@root, "Team change: worker #{@args.worker} (#{@args.harness}) joined as #{@args.role}.")
|
|
64
|
+
@args.dispatch ? start_dispatcher : print_next_steps
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def take_replace(argv)
|
|
70
|
+
index = argv.index("--replace") or return nil
|
|
71
|
+
argv.slice!(index, 2)[1] || abort("maf: --replace needs a worker, for example backend-developer_2")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def prepare
|
|
75
|
+
Maf.flow("--agent", spec) unless Maf.agent_specs.include?("#{@args.harness}:#{@args.role}")
|
|
76
|
+
worktree = SetupAgent::Worktree.ensure(@args.role, @args.worker_id, @args.harness)
|
|
77
|
+
SetupAgent::RoleFile.copy(@root, worktree.dir, @args.harness, @args.role)
|
|
78
|
+
archive_on_harness_change
|
|
79
|
+
Workers.at(@root).add(@args.worker, entry(worktree.dir))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The same worker on a new harness keeps its work, but not the session
|
|
83
|
+
# and the token data of the old harness. The dashboard shows them.
|
|
84
|
+
def archive_on_harness_change
|
|
85
|
+
old = Workers.at(@root).find(@args.worker)
|
|
86
|
+
WorkerArchive.new(@root, @args.worker).harness_change if old && old["harness"] != @args.harness
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def spec = [@args.harness, @args.role, @args.model].compact.join(":")
|
|
90
|
+
|
|
91
|
+
def workers_after
|
|
92
|
+
all = Workers.at(@root).all
|
|
93
|
+
all = all.except(Workers.id(@replace)) if @replace
|
|
94
|
+
all.merge(@args.worker => { "role" => @args.role })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# SetupAgent.run changes into the worktree, so it runs after prepare.
|
|
98
|
+
def start_dispatcher
|
|
99
|
+
Dir.chdir(@root)
|
|
100
|
+
args = [@args.harness, "#{@args.role}_#{@args.worker_id}", "--dispatch", "--detach"]
|
|
101
|
+
SetupAgent.run(@args.model ? args + ["--model", @args.model] : args)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def entry(dir)
|
|
105
|
+
{ "role" => @args.role, "worker_id" => @args.worker_id, "harness" => @args.harness,
|
|
106
|
+
"model" => @args.model, "dispatch" => @args.dispatch, "dir" => dir }.compact
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def print_next_steps
|
|
110
|
+
dir = SetupAgent::Worktree.dir_for(@root, @args.worker)
|
|
111
|
+
puts "", "Worker #{@args.worker} is ready. Run in a new terminal:", " cd #{dir}", " maf start"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# team_command.rb - show the team, or set its limits.
|
|
4
|
+
#
|
|
5
|
+
# maf team
|
|
6
|
+
# maf team set --max N [--allow HARNESS[:MODEL] ...]
|
|
7
|
+
require "json"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require_relative "workers"
|
|
10
|
+
require_relative "budget"
|
|
11
|
+
require_relative "retire"
|
|
12
|
+
require_relative "team"
|
|
13
|
+
|
|
14
|
+
module Maf
|
|
15
|
+
class TeamCommand
|
|
16
|
+
def initialize(argv, root)
|
|
17
|
+
@argv = argv
|
|
18
|
+
@root = root
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run = @argv.first == "set" ? set(@argv.drop(1)) : show
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def show
|
|
26
|
+
puts Budget.at(@root).summary, ""
|
|
27
|
+
workers = Workers.at(@root).all
|
|
28
|
+
puts "No workers yet. Add one: maf prepare HARNESS ROLE[_WORKER]" if workers.empty?
|
|
29
|
+
workers.each { |id, entry| puts line(id, entry) }
|
|
30
|
+
puts "", "Tasks by role:", Team.coord(@root, "status")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def line(id, entry)
|
|
34
|
+
mode = entry["dispatch"] ? "dispatch" : "interactive"
|
|
35
|
+
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry["model"] || "-", mode, state(entry)]
|
|
36
|
+
.join(" ")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def state(entry)
|
|
40
|
+
pid = entry["pid"]
|
|
41
|
+
return "-" unless pid
|
|
42
|
+
|
|
43
|
+
Shared::Processes.alive?(pid) ? "running (pid #{pid})" : "stopped"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The budget keys replace the old budget. Keep every other manifest key,
|
|
47
|
+
# and the timeouts and limits in the team key.
|
|
48
|
+
def set(args)
|
|
49
|
+
team = parse(args)
|
|
50
|
+
path = File.join(@root, ".maf/config.json")
|
|
51
|
+
data = manifest(path)
|
|
52
|
+
File.write(path, JSON.pretty_generate(data.merge("team" => data.fetch("team", {}).merge(team))))
|
|
53
|
+
puts Budget.new(team).summary
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A corrupt manifest must not be overwritten with only the team key: that
|
|
57
|
+
# would drop every agent. Stop with a clear message instead.
|
|
58
|
+
def manifest(path)
|
|
59
|
+
return {} unless File.exist?(path)
|
|
60
|
+
|
|
61
|
+
JSON.parse(File.read(path))
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
abort "maf: #{path} is not valid JSON. Fix it, then run this command again."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse(args)
|
|
67
|
+
team = { "allow" => [] }
|
|
68
|
+
parser = OptionParser.new { |o| o.on("--max N", Integer) { |v| team["max_workers"] = v } }
|
|
69
|
+
parser.on("--allow SPEC") { |v| team["allow"] << v }.parse!(args)
|
|
70
|
+
team
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Uninstall
|
|
5
|
+
# Removes the flow hooks from .claude/settings.json. Other hooks and
|
|
6
|
+
# settings stay. The file goes only if nothing else is left in it.
|
|
7
|
+
class ClaudeSettings
|
|
8
|
+
COMMANDS = Bootstrap::CLAUDE_HOOKS.map { |_, hook| hook["command"] }.uniq.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(project) = @path = File.join(project, ".claude", "settings.json")
|
|
11
|
+
|
|
12
|
+
def steps
|
|
13
|
+
return [] unless File.exist?(@path) && strip(settings) != settings
|
|
14
|
+
|
|
15
|
+
[Step.new("remove flow hooks from #{@path}", -> { clean })]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def settings = (JSON.parse(File.read(@path)) rescue {})
|
|
21
|
+
|
|
22
|
+
def clean
|
|
23
|
+
data = strip(settings)
|
|
24
|
+
data.empty? ? FileUtils.rm(@path) : File.write(@path, JSON.pretty_generate(data))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def strip(data)
|
|
28
|
+
hooks = data.fetch("hooks", {}).transform_values { |entries| strip_event(entries) }.reject { |_, v| v.empty? }
|
|
29
|
+
hooks.empty? ? data.except("hooks") : data.merge("hooks" => hooks)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def strip_event(entries)
|
|
33
|
+
entries.map { |entry| entry.merge("hooks" => entry.fetch("hooks", []).reject { |h| ours?(h) }) }
|
|
34
|
+
.reject { |entry| entry["hooks"].empty? }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ours?(hook) = COMMANDS.include?(hook["command"])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Uninstall
|
|
5
|
+
# Remove project MAF hooks. Preserve other project hooks.
|
|
6
|
+
class CodexHooks < ClaudeSettings
|
|
7
|
+
def initialize(project)
|
|
8
|
+
@path = File.join(project, ".codex", "hooks.json")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def ours?(hook)
|
|
14
|
+
Flow::CodexHooks::COMMANDS.include?(hook["command"])
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Uninstall
|
|
5
|
+
# The commit guard lives in the git hooks dir, outside the working tree.
|
|
6
|
+
class CommitGuard
|
|
7
|
+
def initialize(project) = @project = project
|
|
8
|
+
|
|
9
|
+
def steps
|
|
10
|
+
path = Git.lines(@project, "rev-parse", "--git-path", "hooks").first
|
|
11
|
+
hook = path && File.join(File.expand_path(path, @project), "pre-commit")
|
|
12
|
+
hook && Owned.signed?(hook, Bootstrap::COMMIT_GUARD_SIGNATURE) ? [Owned.remove(hook)] : []
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Uninstall
|
|
5
|
+
class Coordination
|
|
6
|
+
def initialize(project) = @dir = File.join(project, ".maf", "coordination")
|
|
7
|
+
|
|
8
|
+
def steps
|
|
9
|
+
return [] unless Dir.exist?(@dir)
|
|
10
|
+
|
|
11
|
+
[Step.new("remove #{@dir} (task board, messages, locks, hooks)", -> { FileUtils.rm_rf(@dir) })]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Uninstall
|
|
5
|
+
# DocGraphHooks removes the flow block from the post-commit and post-merge
|
|
6
|
+
# hooks. A hook with nothing left but its shebang goes.
|
|
7
|
+
class DocGraphHooks
|
|
8
|
+
EVENTS = %w[post-commit post-merge].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(project) = @project = project
|
|
11
|
+
|
|
12
|
+
def steps
|
|
13
|
+
EVENTS.map { |event| path(event) }.compact.select { |hook| Owned.marked?(hook) }
|
|
14
|
+
.map { |hook| Step.new("remove flow block from #{hook}", -> { clean(hook) }) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def path(event)
|
|
20
|
+
hooks = Git.lines(@project, "rev-parse", "--git-path", "hooks").first
|
|
21
|
+
hooks && File.join(File.expand_path(hooks, @project), event)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clean(hook)
|
|
25
|
+
text = Bootstrap::MarkedBlock.new(File.read(hook)).remove
|
|
26
|
+
return drop(hook) if foreign_lines(text).empty?
|
|
27
|
+
|
|
28
|
+
File.write(hook, "#{text.strip}\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def foreign_lines(text)
|
|
32
|
+
text.lines.map(&:strip).reject { |line| line.empty? || line.start_with?("#!") }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def drop(hook) = FileUtils.rm_f(hook)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Uninstall
|
|
5
|
+
# Removes the settings and the MCP config of the flow in .maf/, and the
|
|
6
|
+
# local git excludes. The excludes keep the kept graphify-out/ dir, so
|
|
7
|
+
# git does not show the generated files.
|
|
8
|
+
class LocalFiles
|
|
9
|
+
DIRS = [%w[.maf claude], %w[.maf mcp]].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(project) = @project = project
|
|
12
|
+
|
|
13
|
+
def steps
|
|
14
|
+
dirs = DIRS.map { |parts| File.join(@project, *parts) }.select { |dir| Dir.exist?(dir) }
|
|
15
|
+
dirs.map { |dir| Step.new("remove #{dir}", -> { FileUtils.rm_rf(dir) }) } + exclude_steps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def exclude_steps
|
|
21
|
+
file = LocalExclude.exclude_path(@project)
|
|
22
|
+
return [] if file.nil? || LocalExclude.listed(file).empty?
|
|
23
|
+
|
|
24
|
+
[Step.new("remove the flow paths from #{file}", -> { keep_generated_dirs })]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def keep_generated_dirs
|
|
28
|
+
kept = KEPT_DIRS.select { |dir| Dir.exist?(File.join(@project, dir)) }.map { |dir| "#{dir}/" }
|
|
29
|
+
kept.empty? ? LocalExclude.remove(@project) : LocalExclude.replace(@project, kept)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|