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
data/lib/maf/flow.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# flow.rb - generate harness-specific role files for the multi-agent flow.
|
|
4
|
+
#
|
|
5
|
+
# maf add, maf remove, maf update, and maf roles run Flow::Generator.
|
|
6
|
+
# Options: --project DIR [--agent HARNESS:ROLE ...] [--remove HARNESS:ROLE ...]
|
|
7
|
+
# [--model ROLE=MODEL] [--hermes-dir DIR] [--check] [--force] [--no-bootstrap]
|
|
8
|
+
# [--list-roles]
|
|
9
|
+
#
|
|
10
|
+
# HARNESS is one of: opencode, claude, codex, hermes.
|
|
11
|
+
# A re-run keeps the agents in .maf/config.json. --agent adds an agent.
|
|
12
|
+
# --remove drops an agent. Without --agent, a re-run regenerates the current agents.
|
|
13
|
+
# Idempotent: identical files are skipped, changed files are updated, and
|
|
14
|
+
# foreign files are refused unless --force is given.
|
|
15
|
+
require "fileutils"
|
|
16
|
+
require "yaml"
|
|
17
|
+
require "erb"
|
|
18
|
+
require "json"
|
|
19
|
+
require "optparse"
|
|
20
|
+
require "rbconfig"
|
|
21
|
+
require "time"
|
|
22
|
+
require_relative "shared/roles"
|
|
23
|
+
|
|
24
|
+
abort "flow: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
|
|
25
|
+
|
|
26
|
+
module Maf
|
|
27
|
+
module Flow
|
|
28
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
29
|
+
TEMPLATES = File.join(ROOT, "templates")
|
|
30
|
+
ASSETS = File.join(ROOT, "assets")
|
|
31
|
+
HARNESSES = %w[opencode claude codex hermes].freeze
|
|
32
|
+
DEFAULT_HERMES_DIR = File.join(Dir.home, ".hermes", "skills")
|
|
33
|
+
|
|
34
|
+
# Model a harness gets when neither --agent nor --model names one.
|
|
35
|
+
# Other harnesses have no safe default, so their CLI picks the model.
|
|
36
|
+
DEFAULT_MODELS = { "claude" => "claude-opus-5-5" }.freeze
|
|
37
|
+
|
|
38
|
+
# Roles that dispatch or coordinate instead of implementing. They are never
|
|
39
|
+
# advertised as dispatch targets in the architect prompt.
|
|
40
|
+
LEADS = Maf::Shared::Roles::LEADS
|
|
41
|
+
DISPATCH_EXCLUDE = LEADS
|
|
42
|
+
|
|
43
|
+
READ_ONLY_TOOLSETS = Maf::Shared::Roles::READ_ONLY_TOOLSETS
|
|
44
|
+
|
|
45
|
+
MAF_DIR = ".maf"
|
|
46
|
+
|
|
47
|
+
# The folder where a harness looks for role files. It is a symlink into
|
|
48
|
+
# .maf/agents/<harness>/, where the flow keeps the role files.
|
|
49
|
+
HARNESS_DIRS = { "opencode" => ".opencode/agents", "claude" => ".claude/agents",
|
|
50
|
+
"codex" => ".codex/prompts" }.freeze
|
|
51
|
+
|
|
52
|
+
# The role file path inside the project. Hermes keeps role files outside
|
|
53
|
+
# the project, so it has no path here.
|
|
54
|
+
def self.role_path(harness, role)
|
|
55
|
+
HARNESS_DIRS.key?(harness) ? File.join(MAF_DIR, "agents", harness, "#{role}.md") : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
require_relative "local_exclude"
|
|
61
|
+
require_relative "flow/prompt_text"
|
|
62
|
+
require_relative "flow/hermes_hook"
|
|
63
|
+
require_relative "flow/roster"
|
|
64
|
+
require_relative "flow/role_catalog"
|
|
65
|
+
require_relative "flow/role_stub"
|
|
66
|
+
require_relative "flow/workflow"
|
|
67
|
+
require_relative "flow/options"
|
|
68
|
+
require_relative "flow/prompt_builder"
|
|
69
|
+
require_relative "flow/hook_files"
|
|
70
|
+
require_relative "flow/hermes_hook_setup"
|
|
71
|
+
require_relative "flow/codex_hooks"
|
|
72
|
+
require_relative "flow/legacy_codex_hook"
|
|
73
|
+
require_relative "flow/hook_installer"
|
|
74
|
+
require_relative "flow/agent_links"
|
|
75
|
+
require_relative "flow/mcp_config"
|
|
76
|
+
require_relative "flow/mcp_installer"
|
|
77
|
+
require_relative "flow/role_files"
|
|
78
|
+
require_relative "flow/manifest"
|
|
79
|
+
require_relative "flow/models"
|
|
80
|
+
require_relative "flow/report"
|
|
81
|
+
require_relative "flow/validator"
|
|
82
|
+
require_relative "flow/harness_linker"
|
|
83
|
+
require_relative "flow/bootstrapper"
|
|
84
|
+
require_relative "flow/generator"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "shared/git_exclude"
|
|
5
|
+
|
|
6
|
+
module Maf
|
|
7
|
+
# LocalExclude keeps the paths of the flow in the .git/info/exclude file of
|
|
8
|
+
# the clone. The flow is a tool, not a part of the project, so nothing that
|
|
9
|
+
# runs it goes into git. info/exclude is local to the clone, and every
|
|
10
|
+
# worktree of the repository shares it. The paths sit in one marked block.
|
|
11
|
+
module LocalExclude
|
|
12
|
+
BEGIN_LINE = "# >>> multi-agent-flow >>>"
|
|
13
|
+
END_LINE = "# <<< multi-agent-flow <<<"
|
|
14
|
+
|
|
15
|
+
# Adds each path that the block does not list yet.
|
|
16
|
+
def self.add(dir, *paths)
|
|
17
|
+
file = exclude_path(dir)
|
|
18
|
+
file && write(file, (listed(file) + paths).uniq)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.replace(dir, paths)
|
|
22
|
+
file = exclude_path(dir)
|
|
23
|
+
file && write(file, paths)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.remove(dir)
|
|
27
|
+
file = exclude_path(dir)
|
|
28
|
+
File.write(file, outside(File.read(file))) if file && File.exist?(file)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.listed(file)
|
|
32
|
+
lines = File.exist?(file) ? File.read(file).lines.map(&:chomp) : []
|
|
33
|
+
first = lines.index(BEGIN_LINE)
|
|
34
|
+
last = lines.index(END_LINE)
|
|
35
|
+
first && last ? lines[(first + 1)...last] : []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.write(file, paths)
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
40
|
+
text = File.exist?(file) ? outside(File.read(file)) : ""
|
|
41
|
+
File.write(file, "#{text.chomp}#{"\n" unless text.strip.empty?}#{[BEGIN_LINE, *paths, END_LINE].join("\n")}\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.outside(text)
|
|
45
|
+
lines = text.lines
|
|
46
|
+
first = lines.index { |line| line.chomp == BEGIN_LINE }
|
|
47
|
+
last = lines.index { |line| line.chomp == END_LINE }
|
|
48
|
+
first && last ? (lines[0...first] + lines[(last + 1)..]).join : text
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.exclude_path(dir) = Maf::Shared::GitExclude.path(dir)
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/maf/menu.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "prompt"
|
|
4
|
+
|
|
5
|
+
module Maf
|
|
6
|
+
# Menu is the interactive mode of maf. It runs the same steps as the
|
|
7
|
+
# subcommands, but asks for each value.
|
|
8
|
+
class Menu
|
|
9
|
+
ACTIONS = { "1" => ["Add agents", :add], "2" => ["Remove an agent", :remove],
|
|
10
|
+
"3" => ["Start an agent", :start], "4" => ["List roles", :roles],
|
|
11
|
+
"5" => ["Update the agent files", :update], "6" => ["Uninstall", :uninstall] }.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(prompt = Prompt.new)
|
|
14
|
+
@prompt = prompt
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
loop do
|
|
19
|
+
show_menu
|
|
20
|
+
break unless handle(@prompt.ask("Choose:"))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Only the installed harnesses are choices. Each role gets its own model.
|
|
25
|
+
def add
|
|
26
|
+
harness = @prompt.choose("Harness", Flow::Models.installed) or return
|
|
27
|
+
roles = @prompt.choose_many("Roles", Maf.role_names)
|
|
28
|
+
return if roles.empty?
|
|
29
|
+
|
|
30
|
+
models = show_models(harness)
|
|
31
|
+
Maf.flow(*roles.flat_map { |role| ["--agent", agent_spec(harness, role, models)] })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def show_menu
|
|
37
|
+
@prompt.say("\nmaf - #{Dir.pwd}")
|
|
38
|
+
guarded { @prompt.say("Agents: #{Maf.agent_specs.join(", ").then { |s| s.empty? ? "none" : s }}") }
|
|
39
|
+
ACTIONS.each { |key, (label, _)| @prompt.say(" #{key}) #{label}") }
|
|
40
|
+
@prompt.say(" q) Quit")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def handle(answer)
|
|
44
|
+
return false if answer.nil? || answer == "q"
|
|
45
|
+
|
|
46
|
+
ACTIONS[answer] ? guarded { send(ACTIONS[answer].last) } : @prompt.say("invalid choice")
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A failed step aborts or raises. The menu prints the error and keeps running.
|
|
51
|
+
def guarded
|
|
52
|
+
yield
|
|
53
|
+
rescue SystemExit
|
|
54
|
+
nil
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
@prompt.say("error: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def agent_spec(harness, role, models)
|
|
60
|
+
model = ask_model(harness, role, models).to_s
|
|
61
|
+
[harness, role, model].reject(&:empty?).join(":")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The answer is a number of the list, a model name, or Enter for the default.
|
|
65
|
+
# A name that the harness does not list needs a confirmation: a typo makes
|
|
66
|
+
# every dispatched run fail.
|
|
67
|
+
def ask_model(harness, role, models)
|
|
68
|
+
answer = @prompt.ask("Model for #{role} (number or name, Enter = #{default_label(harness)}):").to_s
|
|
69
|
+
number = Integer(answer, exception: false)
|
|
70
|
+
return models[number - 1] if number&.between?(1, models.size)
|
|
71
|
+
return answer if answer.empty? || Flow::Models.known?(harness, answer)
|
|
72
|
+
|
|
73
|
+
@prompt.confirm?("#{harness} does not list #{answer}. Use it anyway?") ? answer : ask_model(harness, role, models)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def default_label(harness) = Flow::DEFAULT_MODELS.fetch(harness, "the harness default")
|
|
77
|
+
|
|
78
|
+
def show_models(harness)
|
|
79
|
+
models = Flow::Models.for(harness).to_a
|
|
80
|
+
@prompt.say(models.empty? ? "#{harness} lists no models. Type a name." : "Models of #{harness}:")
|
|
81
|
+
models.each_with_index { |model, i| @prompt.say(" #{i + 1}) #{model}") }
|
|
82
|
+
models
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def remove
|
|
86
|
+
spec = @prompt.choose("Agent", Maf.agent_specs) or return
|
|
87
|
+
Maf.flow("--remove", spec) if @prompt.confirm?("Remove #{spec}?")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def start
|
|
91
|
+
harness, role = (@prompt.choose("Agent", Maf.agent_specs) or return).split(":")
|
|
92
|
+
worker = @prompt.ask("Worker id (Enter = default):").to_s
|
|
93
|
+
dispatch = @prompt.confirm?("Run unattended (--dispatch)?")
|
|
94
|
+
SetupAgent.run([harness, worker.empty? ? role : "#{role}_#{worker}", *("--dispatch" if dispatch)])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def roles = Maf.flow("--list-roles")
|
|
98
|
+
def update = Maf.flow
|
|
99
|
+
def uninstall = Uninstall::Runner.new(["--project", Dir.pwd]).run
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Migrate
|
|
5
|
+
# Moves plans the move of scripts, folders, and the config file.
|
|
6
|
+
class Moves
|
|
7
|
+
def initialize(project)
|
|
8
|
+
@project = project
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def steps
|
|
12
|
+
scripts + folders + config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def scripts
|
|
18
|
+
SCRIPTS.select { |old, _, signature| Migrate.signed?(path(old), signature) }
|
|
19
|
+
.map { |old, new, _| move(old, new) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def folders
|
|
23
|
+
FOLDERS.select { |old, _| File.directory?(path(old)) }.map { |old, new| move(old, new) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def config
|
|
27
|
+
File.exist?(path(".agent-flow.json")) ? [move(".agent-flow.json", ".maf/config.json")] : []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def move(old, new)
|
|
31
|
+
return Step.new("keep #{old} (#{new} exists)", -> {}) if File.exist?(path(new))
|
|
32
|
+
|
|
33
|
+
Step.new("move #{old} -> #{new}", -> { relocate(path(old), path(new)) })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def relocate(from, to)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(to))
|
|
38
|
+
FileUtils.mv(from, to)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def path(*parts) = File.join(@project, *parts)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Migrate
|
|
5
|
+
# Rewrites changes the old paths inside files that stay in place or move.
|
|
6
|
+
# The files are the taskrc, the worker registry, and the Claude settings.
|
|
7
|
+
class Rewrites
|
|
8
|
+
def initialize(project)
|
|
9
|
+
@project = File.realpath(project)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def steps
|
|
13
|
+
[taskrc, workers, claude_settings].compact
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# data.location of the taskrc holds the absolute path of the task database.
|
|
19
|
+
def taskrc
|
|
20
|
+
rewrite(".maf/coordination/taskrc", [["#{@project}/coordination", "#{@project}/.maf/coordination"]])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def workers
|
|
24
|
+
rewrite(".maf/coordination/workers.json", [["#{@project}/.worktrees", "#{@project}/.maf/worktrees"]])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def claude_settings
|
|
28
|
+
rewrite(".claude/settings.json", %w[next-task board-watch].map { |name| hook_paths(name) })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hook_paths(name)
|
|
32
|
+
["ruby coordination/harness-hooks/#{name}.rb", "ruby .maf/coordination/harness-hooks/#{name}.rb"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rewrite(rel, pairs)
|
|
36
|
+
file = find(rel)
|
|
37
|
+
return unless file && pairs.any? { |old, _| File.read(file).include?(old) }
|
|
38
|
+
|
|
39
|
+
Step.new("edit #{rel}", -> { apply(rel, pairs) })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply(rel, pairs)
|
|
43
|
+
file = find(rel)
|
|
44
|
+
File.write(file, pairs.reduce(File.read(file)) { |text, (old, new)| text.gsub(old, new) })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The folder moves in an earlier step. The plan still sees the old place.
|
|
48
|
+
def find(rel)
|
|
49
|
+
[File.join(@project, rel), File.join(@project, rel.delete_prefix(".maf/"))].find { |file| File.file?(file) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Migrate
|
|
5
|
+
# RoleFiles moves the role files of the flow from the folders of the
|
|
6
|
+
# harnesses into .maf/agents/<harness>/. A file without the marker stays.
|
|
7
|
+
# The regeneration step then replaces an empty harness folder with a symlink.
|
|
8
|
+
class RoleFiles
|
|
9
|
+
def initialize(project)
|
|
10
|
+
@project = project
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def steps
|
|
14
|
+
Flow::HARNESS_DIRS.flat_map { |harness, dir| files(dir).map { |file| step(harness, dir, file) } }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def files(dir)
|
|
20
|
+
folder = File.join(@project, dir)
|
|
21
|
+
return [] if File.symlink?(folder)
|
|
22
|
+
|
|
23
|
+
Dir.glob(File.join(folder, "*.md")).select { |file| File.read(file).include?(Bootstrap::MARKER) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def step(harness, dir, file)
|
|
27
|
+
new = File.join(@project, ".maf", "agents", harness, File.basename(file))
|
|
28
|
+
label = "move #{dir}/#{File.basename(file)} -> .maf/agents/#{harness}/#{File.basename(file)}"
|
|
29
|
+
return Step.new("keep #{dir}/#{File.basename(file)} (the new file exists)", -> {}) if File.exist?(new)
|
|
30
|
+
|
|
31
|
+
Step.new(label, -> { FileUtils.mkdir_p(File.dirname(new)) && FileUtils.mv(file, new) })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Migrate
|
|
5
|
+
# Runner plans the migration, prints the plan, asks, and applies it.
|
|
6
|
+
class Runner
|
|
7
|
+
USAGE = "Usage: maf migrate [--check] [--yes]"
|
|
8
|
+
|
|
9
|
+
def initialize(argv)
|
|
10
|
+
@opts = {}
|
|
11
|
+
parser = OptionParser.new(USAGE) { |o| ["--project DIR", "--check", "--yes"].each { |f| o.on(f) } }
|
|
12
|
+
parser.parse!(argv, into: @opts)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
steps = plan
|
|
17
|
+
return say("nothing to migrate in #{project}") if steps.empty?
|
|
18
|
+
|
|
19
|
+
steps.each { |step| say(step.label) }
|
|
20
|
+
@opts[:check] ? say("check only; nothing moved") : apply(steps)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def project
|
|
26
|
+
abort USAGE unless @opts[:project] && Dir.exist?(@opts[:project])
|
|
27
|
+
@project ||= File.realpath(@opts[:project])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def plan
|
|
31
|
+
return [] unless Migrate.old_layout?(project)
|
|
32
|
+
|
|
33
|
+
[Moves, Worktrees, Rewrites, RoleFiles].flat_map { |planner| planner.new(project).steps }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def apply(steps)
|
|
37
|
+
abort "migrate: cancelled" unless @opts[:yes] || confirmed?
|
|
38
|
+
abort "migrate: a worker still runs. Stop it, then run this again." if running_workers.any?
|
|
39
|
+
steps.each(&:run)
|
|
40
|
+
regenerate
|
|
41
|
+
say("moved the flow into #{project}/.maf. Review with git status. Then commit the move.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def confirmed?
|
|
45
|
+
print "Move the files above? [y/N] "
|
|
46
|
+
$stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A dispatcher in the background keeps the old paths. The registry lists its pid.
|
|
50
|
+
def running_workers
|
|
51
|
+
paths = [".maf/coordination", "coordination"].map { |dir| File.join(project, dir, "workers.json") }
|
|
52
|
+
path = paths.find { |file| File.exist?(file) }
|
|
53
|
+
pids = path ? JSON.parse(File.read(path)).values.filter_map { |entry| entry["pid"] } : []
|
|
54
|
+
pids.select { |pid| Maf::Shared::Processes.alive?(pid) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Regenerate the files of the current agents: scripts, role files, symlinks,
|
|
58
|
+
# hooks, and the marked blocks. This is the same step as maf update.
|
|
59
|
+
def regenerate
|
|
60
|
+
Flow::Generator.new(["--project", project]).run if File.exist?(File.join(project, ".maf", "config.json"))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def say(message) = puts("[multi-agent-flow] #{message}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
module Migrate
|
|
5
|
+
# Worktrees moves each git worktree of .worktrees/ into .maf/worktrees/.
|
|
6
|
+
# git worktree move keeps the link between the worktree and the repository.
|
|
7
|
+
# The env file of a worktree moves to .maf/env.sh with the new paths.
|
|
8
|
+
# Git excludes .maf/env.sh, so the worktree stays clean.
|
|
9
|
+
class Worktrees
|
|
10
|
+
ENV_BIN = "export MAF_BIN=%<bin>s\n" \
|
|
11
|
+
"case \":$PATH:\" in *\":$MAF_BIN:\"*) ;; *) export PATH=\"$MAF_BIN:$PATH\" ;; esac\n"
|
|
12
|
+
|
|
13
|
+
def initialize(project)
|
|
14
|
+
@project = File.realpath(project)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def steps
|
|
18
|
+
old_dirs.map do |dir|
|
|
19
|
+
new = dir.sub("/.worktrees/", "/.maf/worktrees/")
|
|
20
|
+
Step.new("move #{rel(dir)} -> #{rel(new)} (git worktree move)", -> { move(dir, new) })
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def old_dirs
|
|
27
|
+
root = File.join(@project, ".worktrees", "")
|
|
28
|
+
IO.popen(["git", "-C", @project, "worktree", "list", "--porcelain"], err: File::NULL, &:readlines)
|
|
29
|
+
.filter_map { |line| line[/\Aworktree (.+)/, 1] }.select { |dir| dir.start_with?(root) }
|
|
30
|
+
rescue Errno::ENOENT
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def move(old, new)
|
|
35
|
+
FileUtils.mkdir_p(File.dirname(new))
|
|
36
|
+
moved = system("git", "-C", @project, "worktree", "move", old, new)
|
|
37
|
+
abort "migrate: git worktree move failed for #{old}" unless moved
|
|
38
|
+
env_file(new)
|
|
39
|
+
Dir.rmdir(File.join(@project, ".worktrees")) if Dir.empty?(File.join(@project, ".worktrees"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The old env file holds the absolute paths of the old coordination folder.
|
|
43
|
+
def env_file(dir)
|
|
44
|
+
old = File.join(dir, "coord-env.sh")
|
|
45
|
+
return unless File.file?(old) && File.read(old).include?("COORD_DIR=")
|
|
46
|
+
|
|
47
|
+
write_env(dir, converted(File.read(old)))
|
|
48
|
+
EnvExclude.add(dir)
|
|
49
|
+
FileUtils.rm(old)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write_env(dir, text)
|
|
53
|
+
FileUtils.mkdir_p(File.join(dir, ".maf"))
|
|
54
|
+
File.write(File.join(dir, ".maf", "env.sh"), text)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def converted(text)
|
|
58
|
+
text.gsub("#{@project}/coordination", "#{@project}/.maf/coordination") +
|
|
59
|
+
format(ENV_BIN, bin: File.join(@project, ".maf", "bin"))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rel(path) = path.delete_prefix("#{@project}/")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/maf/migrate.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# migrate.rb - move an old-layout install of the multi-agent flow into .maf/.
|
|
4
|
+
#
|
|
5
|
+
# maf migrate runs Migrate::Runner. Options: --project DIR [--check] [--yes]
|
|
6
|
+
#
|
|
7
|
+
# Old layout: coord, dispatcher, vault, dashboard, coordination/, .worktrees/,
|
|
8
|
+
# graphify-out/, obsidian/, .agent-flow.json at the project root.
|
|
9
|
+
# New layout: everything in .maf/, except graphify-out/ at the root, which
|
|
10
|
+
# holds the graph and the Obsidian vault (see USER_MANUAL.md).
|
|
11
|
+
#
|
|
12
|
+
# Moves files. Never deletes a file. Moves a script only if it carries the
|
|
13
|
+
# flow signature, and a role file only if it carries the marker. If the new
|
|
14
|
+
# path exists, the old file stays and the plan says so.
|
|
15
|
+
# After the moves, the runner regenerates the files of the current agents,
|
|
16
|
+
# like maf update.
|
|
17
|
+
require "fileutils"
|
|
18
|
+
require "json"
|
|
19
|
+
require "optparse"
|
|
20
|
+
require_relative "bootstrap"
|
|
21
|
+
require_relative "flow"
|
|
22
|
+
require_relative "env_exclude"
|
|
23
|
+
require_relative "shared/processes"
|
|
24
|
+
|
|
25
|
+
module Maf
|
|
26
|
+
module Migrate
|
|
27
|
+
# Step is one change. --check prints the label and runs nothing.
|
|
28
|
+
Step = Struct.new(:label, :work) do
|
|
29
|
+
def run = work.call
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The scripts of the old layout: [old path, new path, signature].
|
|
33
|
+
SCRIPTS = [
|
|
34
|
+
["coord", ".maf/bin/coord", Bootstrap::COORD_SIGNATURE],
|
|
35
|
+
["dispatcher", ".maf/bin/dispatcher", Bootstrap::DISPATCHER_SIGNATURE],
|
|
36
|
+
["dashboard", ".maf/bin/dashboard", Bootstrap::DASHBOARD_SIGNATURE],
|
|
37
|
+
["vault", ".maf/bin/vault", Bootstrap::VAULT_SIGNATURE],
|
|
38
|
+
["vault-daemon", ".maf/bin/vault", Bootstrap::VAULT_SIGNATURE],
|
|
39
|
+
["coordination/doc-graph-refresh", ".maf/bin/doc-graph-refresh", Bootstrap::DOC_GRAPH_SIGNATURE]
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# The folders of the old layout: [old path, new path].
|
|
43
|
+
FOLDERS = [["coordination", ".maf/coordination"], ["obsidian", "graphify-out/obsidian"]].freeze
|
|
44
|
+
|
|
45
|
+
HINT = "This project uses the old layout. Run: maf migrate"
|
|
46
|
+
|
|
47
|
+
# True if the project has files of the old layout.
|
|
48
|
+
def self.old_layout?(project)
|
|
49
|
+
File.exist?(File.join(project, ".agent-flow.json")) ||
|
|
50
|
+
File.exist?(File.join(project, "coordination", "taskrc")) ||
|
|
51
|
+
SCRIPTS.any? { |old, _, signature| signed?(File.join(project, old), signature) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.signed?(path, signature) = File.file?(path) && File.read(path).include?(signature)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
require_relative "migrate/moves"
|
|
59
|
+
require_relative "migrate/worktrees"
|
|
60
|
+
require_relative "migrate/rewrites"
|
|
61
|
+
require_relative "migrate/role_files"
|
|
62
|
+
require_relative "migrate/runner"
|
data/lib/maf/prompt.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maf
|
|
4
|
+
# Prompt asks questions on a terminal. The user picks items by number.
|
|
5
|
+
# Each question returns nil at the end of the input.
|
|
6
|
+
class Prompt
|
|
7
|
+
def initialize(input = $stdin, output = $stdout)
|
|
8
|
+
@input = input
|
|
9
|
+
@output = output
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def say(text) = @output.puts(text)
|
|
13
|
+
|
|
14
|
+
def ask(question)
|
|
15
|
+
@output.print("#{question} ")
|
|
16
|
+
@input.gets&.strip
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def choose(title, items)
|
|
20
|
+
show(items)
|
|
21
|
+
pick(ask("#{title}:"), items)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def choose_many(title, items)
|
|
25
|
+
show(items)
|
|
26
|
+
ask("#{title} (numbers, comma separated):").to_s.split(",").filter_map { |answer| pick(answer, items) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def confirm?(question) = ask("#{question} [y/N]").to_s.downcase == "y"
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def show(items) = items.each_with_index { |item, i| say(" #{i + 1}) #{item}") }
|
|
34
|
+
|
|
35
|
+
def pick(answer, items)
|
|
36
|
+
number = Integer(answer.to_s.strip, exception: false)
|
|
37
|
+
number&.between?(1, items.size) ? items[number - 1] : say("invalid choice")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|