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.
Files changed (136) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +411 -0
  5. data/assets/agents-contract.md +80 -0
  6. data/assets/analyst +240 -0
  7. data/assets/coord +2936 -0
  8. data/assets/dashboard +553 -0
  9. data/assets/dashboard.html +341 -0
  10. data/assets/dispatcher +1687 -0
  11. data/assets/doc-graph-refresh +286 -0
  12. data/assets/env.sh +6 -0
  13. data/assets/git-hooks/post-commit +7 -0
  14. data/assets/git-hooks/post-merge +7 -0
  15. data/assets/git-hooks/pre-commit +32 -0
  16. data/assets/harness-hooks/board-watch-opencode.js +87 -0
  17. data/assets/harness-hooks/board-watch.rb +286 -0
  18. data/assets/harness-hooks/context-watch.rb +268 -0
  19. data/assets/harness-hooks/next-task-hermes.sh +48 -0
  20. data/assets/harness-hooks/next-task.rb +97 -0
  21. data/assets/harness-hooks/session-guard.rb +128 -0
  22. data/assets/taskrc.append +11 -0
  23. data/assets/vault +224 -0
  24. data/assets/worktree-env.example.rb +26 -0
  25. data/exe/maf +14 -0
  26. data/install.md +326 -0
  27. data/lib/maf/bootstrap/claude_settings.rb +55 -0
  28. data/lib/maf/bootstrap/dependencies.rb +37 -0
  29. data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
  30. data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
  31. data/lib/maf/bootstrap/graph_home.rb +62 -0
  32. data/lib/maf/bootstrap/hook_merger.rb +53 -0
  33. data/lib/maf/bootstrap/installer.rb +66 -0
  34. data/lib/maf/bootstrap/layout_planner.rb +18 -0
  35. data/lib/maf/bootstrap/marked_block.rb +44 -0
  36. data/lib/maf/bootstrap/memory_branch.rb +77 -0
  37. data/lib/maf/bootstrap/options.rb +34 -0
  38. data/lib/maf/bootstrap/project.rb +77 -0
  39. data/lib/maf/bootstrap/script_planner.rb +81 -0
  40. data/lib/maf/bootstrap/text_planner.rb +42 -0
  41. data/lib/maf/bootstrap/vault_starter.rb +41 -0
  42. data/lib/maf/bootstrap/writer.rb +69 -0
  43. data/lib/maf/bootstrap.rb +162 -0
  44. data/lib/maf/budget.rb +59 -0
  45. data/lib/maf/cli.rb +135 -0
  46. data/lib/maf/env_exclude.rb +23 -0
  47. data/lib/maf/flow/agent_links.rb +79 -0
  48. data/lib/maf/flow/bootstrapper.rb +36 -0
  49. data/lib/maf/flow/codex_hooks.rb +50 -0
  50. data/lib/maf/flow/generator.rb +63 -0
  51. data/lib/maf/flow/harness_linker.rb +37 -0
  52. data/lib/maf/flow/hermes_hook.rb +48 -0
  53. data/lib/maf/flow/hermes_hook_setup.rb +69 -0
  54. data/lib/maf/flow/hook_files.rb +16 -0
  55. data/lib/maf/flow/hook_installer.rb +33 -0
  56. data/lib/maf/flow/legacy_codex_hook.rb +71 -0
  57. data/lib/maf/flow/manifest.rb +51 -0
  58. data/lib/maf/flow/mcp_config.rb +72 -0
  59. data/lib/maf/flow/mcp_installer.rb +45 -0
  60. data/lib/maf/flow/models.rb +61 -0
  61. data/lib/maf/flow/options.rb +65 -0
  62. data/lib/maf/flow/prompt_builder.rb +85 -0
  63. data/lib/maf/flow/prompt_text.rb +263 -0
  64. data/lib/maf/flow/report.rb +89 -0
  65. data/lib/maf/flow/role_catalog.rb +40 -0
  66. data/lib/maf/flow/role_files.rb +72 -0
  67. data/lib/maf/flow/role_stub.rb +38 -0
  68. data/lib/maf/flow/roster.rb +28 -0
  69. data/lib/maf/flow/validator.rb +38 -0
  70. data/lib/maf/flow/workflow.rb +28 -0
  71. data/lib/maf/flow.rb +84 -0
  72. data/lib/maf/local_exclude.rb +53 -0
  73. data/lib/maf/menu.rb +101 -0
  74. data/lib/maf/migrate/moves.rb +44 -0
  75. data/lib/maf/migrate/rewrites.rb +53 -0
  76. data/lib/maf/migrate/role_files.rb +35 -0
  77. data/lib/maf/migrate/runner.rb +66 -0
  78. data/lib/maf/migrate/worktrees.rb +65 -0
  79. data/lib/maf/migrate.rb +62 -0
  80. data/lib/maf/prompt.rb +40 -0
  81. data/lib/maf/retire.rb +116 -0
  82. data/lib/maf/role_limits.rb +49 -0
  83. data/lib/maf/setup_agent/args.rb +57 -0
  84. data/lib/maf/setup_agent/dispatch.rb +44 -0
  85. data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
  86. data/lib/maf/setup_agent/hermes_skill.rb +26 -0
  87. data/lib/maf/setup_agent/launcher.rb +85 -0
  88. data/lib/maf/setup_agent/manifest.rb +35 -0
  89. data/lib/maf/setup_agent/project.rb +9 -0
  90. data/lib/maf/setup_agent/role_file.rb +30 -0
  91. data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
  92. data/lib/maf/setup_agent/worktree.rb +50 -0
  93. data/lib/maf/setup_agent.rb +111 -0
  94. data/lib/maf/shared/git_exclude.rb +33 -0
  95. data/lib/maf/shared/git_identity.rb +41 -0
  96. data/lib/maf/shared/peak_rate.rb +20 -0
  97. data/lib/maf/shared/processes.rb +31 -0
  98. data/lib/maf/shared/project.rb +34 -0
  99. data/lib/maf/shared/roles.rb +19 -0
  100. data/lib/maf/team.rb +114 -0
  101. data/lib/maf/team_command.rb +73 -0
  102. data/lib/maf/uninstall/claude_settings.rb +40 -0
  103. data/lib/maf/uninstall/codex_hooks.rb +18 -0
  104. data/lib/maf/uninstall/commit_guard.rb +16 -0
  105. data/lib/maf/uninstall/coordination.rb +15 -0
  106. data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
  107. data/lib/maf/uninstall/git.rb +13 -0
  108. data/lib/maf/uninstall/local_files.rb +33 -0
  109. data/lib/maf/uninstall/manifest.rb +29 -0
  110. data/lib/maf/uninstall/marked_files.rb +37 -0
  111. data/lib/maf/uninstall/mcp_entries.rb +43 -0
  112. data/lib/maf/uninstall/notes.rb +31 -0
  113. data/lib/maf/uninstall/owned.rb +12 -0
  114. data/lib/maf/uninstall/role_files.rb +51 -0
  115. data/lib/maf/uninstall/runner.rb +67 -0
  116. data/lib/maf/uninstall/scripts.rb +35 -0
  117. data/lib/maf/uninstall/vault_watcher.rb +21 -0
  118. data/lib/maf/uninstall/worktrees.rb +30 -0
  119. data/lib/maf/uninstall.rb +59 -0
  120. data/lib/maf/untrack.rb +90 -0
  121. data/lib/maf/version.rb +5 -0
  122. data/lib/maf/worker_archive.rb +63 -0
  123. data/lib/maf/worker_control.rb +137 -0
  124. data/lib/maf/workers.rb +37 -0
  125. data/lib/maf.rb +5 -0
  126. data/templates/claude.md.erb +16 -0
  127. data/templates/codex.md.erb +7 -0
  128. data/templates/hermes.md.erb +12 -0
  129. data/templates/opencode.md.erb +24 -0
  130. data/templates/role-stub.yml.erb +15 -0
  131. data/templates/roles.yml +289 -0
  132. data/templates/workflows/panel.md +20 -0
  133. data/templates/workflows/plan-review.md +9 -0
  134. data/templates/workflows/simple.md +4 -0
  135. data/templates/workflows/tdd.md +8 -0
  136. 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
@@ -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