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