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,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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ module Git
6
+ def self.lines(project, *args)
7
+ IO.popen(["git", "-C", project, *args], err: File::NULL, &:readlines).map(&:chomp)
8
+ rescue Errno::ENOENT
9
+ []
10
+ end
11
+ end
12
+ end
13
+ 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