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/retire.rb ADDED
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "setup_agent"
4
+ require_relative "workers"
5
+ require_relative "worker_archive"
6
+ require_relative "shared/processes"
7
+
8
+ module Maf
9
+ class Retire
10
+ RUNTIME_PREFIXES = %w[.maf/ .claude/ .opencode/ .codex/].freeze
11
+
12
+ def initialize(root, spec)
13
+ @root = root
14
+ @worker = Workers.id(spec)
15
+ @dir = SetupAgent::Worktree.dir_for(root, @worker)
16
+ end
17
+
18
+ def check!
19
+ abort "maf: no worker #{@worker}" unless Workers.at(@root).find(@worker) || Dir.exist?(@dir)
20
+ stop_dispatcher
21
+ pids = RunningProcesses.in(@dir)
22
+ abort "maf: #{@worker} still runs (pid #{pids.join(", ")}). Stop its session, then run this again." if pids.any?
23
+ abort "maf: #{@dir} has uncommitted work. Commit it on the task branch, then run this again." if dirty?
24
+ end
25
+
26
+ def run
27
+ check!
28
+ released = release_tasks
29
+ remove_worker
30
+ Team.notify(@root, "Team change: worker #{@worker} left. #{released} task(s) returned to the pool.")
31
+ puts "Worker #{@worker} retired. #{released} task(s) returned to the pool."
32
+ end
33
+
34
+ private
35
+
36
+ def role = Workers.at(@root).find(@worker)&.fetch("role") || @worker.sub(/-[^-]+\z/, "")
37
+
38
+ def stop_dispatcher
39
+ pid = Workers.at(@root).find(@worker)&.fetch("pid", nil)
40
+ return unless pid && Shared::Processes.alive?(pid)
41
+
42
+ puts "Stopping the dispatcher of #{@worker} (pid #{pid}). A running agent finishes first."
43
+ RunningProcesses.terminate(pid)
44
+ abort "maf: pid #{pid} did not stop. Stop it with: kill #{pid}" unless RunningProcesses.wait_for_exit(pid)
45
+ end
46
+
47
+ def release_tasks
48
+ env = { "COORD_ROLE" => role, "COORD_WORKER" => @worker }
49
+ ids = Team.coord(@root, "next", "--mine", env: env).scan(/^[0-9a-f-]{36}/)
50
+ ids.each { |id| release(id) }
51
+ ids.size
52
+ end
53
+
54
+ def release(id)
55
+ Team.coord(@root, "unclaim", id)
56
+ Team.coord(@root, "annotate", id, "Worker #{@worker} was retired. Continue on the existing task branch.")
57
+ end
58
+
59
+ def remove_presence
60
+ coord_dir = File.join(@root, ".maf", "coordination")
61
+ FileUtils.rm_f(File.join(coord_dir, "presence", "#{@worker}.json"))
62
+ end
63
+
64
+ def remove_worker
65
+ remove_worktree
66
+ WorkerArchive.new(@root, @worker).run
67
+ remove_presence
68
+ Workers.at(@root).remove(@worker)
69
+ end
70
+
71
+ def dirty?
72
+ return false unless Dir.exist?(@dir)
73
+
74
+ lines = IO.popen(["git", "-C", @dir, "status", "--porcelain"], err: File::NULL, &:readlines)
75
+ lines.map { |line| line[3..].strip }.any? { |path| RUNTIME_PREFIXES.none? { |prefix| path.start_with?(prefix) } }
76
+ end
77
+
78
+ def remove_worktree
79
+ return unless Dir.exist?(@dir)
80
+
81
+ system("git", "-C", @root, "worktree", "remove", "--force", @dir, out: File::NULL) ||
82
+ warn("maf: could not remove #{@dir}. Remove it with: git worktree remove #{@dir}")
83
+ end
84
+ end
85
+
86
+ # RunningProcesses finds processes whose working directory is inside DIR.
87
+ # A harness session or a dispatcher runs in its worktree.
88
+ module RunningProcesses
89
+ def self.in(dir)
90
+ out = IO.popen(%w[lsof -a -d cwd -Fpn], err: File::NULL, &:read)
91
+ pairs(out).select { |_pid, path| path == dir || path.start_with?("#{dir}/") }.map(&:first) - [Process.pid]
92
+ rescue Errno::ENOENT
93
+ warn "maf: lsof not found. Cannot check if the worker still runs."
94
+ []
95
+ end
96
+
97
+ # TERM lets a running agent finish its run first.
98
+ def self.terminate(pid)
99
+ Process.kill("TERM", pid)
100
+ rescue Errno::EPERM
101
+ abort "maf: pid #{pid} belongs to another user. Stop it with: sudo kill #{pid}"
102
+ end
103
+
104
+ # A dispatcher run can take up to its --timeout (default 1500 seconds).
105
+ def self.wait_for_exit(pid, timeout: Integer(ENV.fetch("MAF_STOP_TIMEOUT", "1800")))
106
+ deadline = Time.now + timeout
107
+ sleep 0.5 while Shared::Processes.alive?(pid) && Time.now < deadline
108
+ !Shared::Processes.alive?(pid)
109
+ end
110
+
111
+ def self.pairs(out)
112
+ out.lines(chomp: true).slice_before { |line| line.start_with?("p") }
113
+ .map { |group| [group.first[1..].to_i, group.find { |line| line.start_with?("n") }.to_s[1..].to_s] }
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # role_limits.rb - save the session limits of a role in .maf/config.json.
4
+ #
5
+ # maf worker restart frontend-developer_bot --max-context 80000
6
+ require "json"
7
+
8
+ module Maf
9
+ # RoleLimits reads the limit flags of `maf worker start|restart` and saves
10
+ # them under team.limits.<role> in .maf/config.json. The dispatcher reads
11
+ # them when it starts, so a restart applies them. A flag of
12
+ # `maf start --dispatch` still wins over a saved limit.
13
+ class RoleLimits
14
+ FLAGS = { "--max-context" => "max_context", "--max-session-runs" => "max_session_runs",
15
+ "--cache-window" => "cache_window" }.freeze
16
+
17
+ def self.parse(args) = FLAGS.filter_map { |flag, key| pair(args, flag, key) }.to_h
18
+ def self.pair(args, flag, key) = (index = args.index(flag)) && [key, number(flag, args[index + 1])]
19
+
20
+ def self.number(flag, text)
21
+ value = Integer(text.to_s, exception: false)
22
+ value && value >= 0 ? value : abort("maf: #{flag} needs a number of 0 or more")
23
+ end
24
+
25
+ def initialize(root) = @path = File.join(root, ".maf", "config.json")
26
+
27
+ def save(role, limits)
28
+ return if limits.empty?
29
+
30
+ File.write(@path, JSON.pretty_generate(merged(manifest, role, limits)))
31
+ puts "Saved limits of #{role}: #{limits.map { |key, value| "#{key}=#{value}" }.join(" ")}"
32
+ end
33
+
34
+ private
35
+
36
+ def merged(data, role, limits)
37
+ saved = data.dig("team", "limits").to_h
38
+ team = data.fetch("team", {}).merge("limits" => saved.merge(role => saved[role].to_h.merge(limits)))
39
+ data.merge("team" => team)
40
+ end
41
+
42
+ # A corrupt manifest must not be overwritten. Stop with a clear message instead.
43
+ def manifest
44
+ File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
45
+ rescue JSON::ParserError
46
+ abort "maf: #{@path} is not valid JSON. Fix it, then run this command again."
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module SetupAgent
5
+ # Args reads the positional arguments (HARNESS ROLE[_WORKER] [model:M]),
6
+ # then the flags. --dispatch and --model M are setup_agent's own flags; all
7
+ # other flags go to the dispatcher and need --dispatch.
8
+ class Args
9
+ Parsed = Struct.new(:harness, :role, :worker_id, :worker, :model, :dispatch, :detach, :dispatcher_args,
10
+ keyword_init: true)
11
+
12
+ def self.parse(argv)
13
+ dispatch, detach, positional, flags = split(argv)
14
+ model = take_model(flags)
15
+ reject_stray(flags, dispatch)
16
+ abort "setup_agent: --detach works only with --dispatch" if detach && !dispatch
17
+ Parsed.new(**identity(positional, model, dispatch), dispatch: dispatch, detach: detach, dispatcher_args: flags)
18
+ end
19
+
20
+ def self.split(argv)
21
+ rest = argv - %w[--dispatch --detach]
22
+ index = rest.index { |arg| arg.start_with?("--") } || rest.size
23
+ [argv.include?("--dispatch"), argv.include?("--detach"), rest[0...index], rest[index..-1]]
24
+ end
25
+
26
+ def self.identity(positional, model, dispatch)
27
+ harness, role_spec, model_arg = positional
28
+ abort_usage if harness.nil? || role_spec.nil?
29
+ role, worker_id = split_role(role_spec, dispatch ? "bot" : "1")
30
+ { harness: harness, role: role, worker_id: worker_id, worker: "#{role}-#{worker_id}",
31
+ model: model || model_arg&.sub(/\Amodel:/, "") }
32
+ end
33
+
34
+ def self.take_model(flags)
35
+ index = flags.index("--model")
36
+ value = index && flags.slice!(index, 2)[1]
37
+ abort "setup_agent: --model needs a value" if index && (value.nil? || value.start_with?("--"))
38
+ value
39
+ end
40
+
41
+ def self.reject_stray(flags, dispatch)
42
+ return if flags.empty? || dispatch
43
+
44
+ abort "setup_agent: #{flags.join(" ")} works only with --dispatch"
45
+ end
46
+
47
+ def self.split_role(spec, default_id = "1")
48
+ role, _sep, worker_id = spec.rpartition("_")
49
+ role.empty? ? [spec, default_id] : [role, worker_id]
50
+ end
51
+
52
+ def self.abort_usage
53
+ abort "usage: maf start HARNESS ROLE[_WORKER] [model:MODEL] [--model MODEL] [--dispatch [FLAGS...]]"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module SetupAgent
5
+ # Dispatch runs dispatcher in the worktree instead of an interactive
6
+ # session. The dispatcher of the main checkout is used: maf update
7
+ # refreshes that copy, not the copy in a worktree. The current Ruby runs
8
+ # it, so an old system Ruby on the shebang path never parses it.
9
+ module Dispatch
10
+ DISPATCHER = ".maf/bin/dispatcher"
11
+
12
+ def self.launch(args, model)
13
+ dispatcher = File.join(Project.root, DISPATCHER)
14
+ require_dispatcher!(dispatcher)
15
+ cmd = [RbConfig.ruby, dispatcher, args.role, "--harness", args.harness]
16
+ cmd += ["--model", model] if model
17
+ cmd += args.dispatcher_args
18
+ args.detach ? detach(cmd, args.worker) : Launcher.exec_or_die(cmd)
19
+ end
20
+
21
+ # setsid gives the dispatcher its own session, so it keeps running when
22
+ # the terminal or the harness session that started it ends.
23
+ def self.detach(cmd, worker)
24
+ log = File.join(ENV.fetch("COORD_DIR"), "sessions", "#{worker}.log")
25
+ pid = spawn_detached(cmd, log)
26
+ Maf::Workers.at(Project.root).update(worker, "pid" => pid)
27
+ puts "Started #{worker} in the background (pid #{pid}). Log: #{log}"
28
+ end
29
+
30
+ def self.spawn_detached(cmd, log)
31
+ FileUtils.mkdir_p(File.dirname(log))
32
+ io = { in: File::NULL, out: [log, "a"], err: %i[child out] }
33
+ pid = fork { Process.setsid && exec({ "DISPATCHER_LOG" => log, "PWD" => Dir.pwd }, *cmd, **io) }
34
+ Process.detach(pid) && pid
35
+ end
36
+
37
+ def self.require_dispatcher!(dispatcher)
38
+ return if File.exist?(dispatcher)
39
+
40
+ abort "setup_agent: #{dispatcher} is missing. Run maf update in the main project, then start again."
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module SetupAgent
5
+ module Launcher
6
+ # Hermes loads role files as skills from <hermes_dir>/<project>-<role>/.
7
+ # flow.rb writes the skill there; --skills <project>-<role> loads it.
8
+ # Hermes has no --agent flag; the skill's SKILL.md is the role definition.
9
+ # `-q` on a TTY seeds an interactive session with the prompt.
10
+ # A lead role owns no task, so it gets an inbox prompt. A role with
11
+ # can_edit false starts without the file-writing toolsets.
12
+ module Hermes
13
+ PROMPT = "Follow the coordination rules of your role skill as role %<role>s. " \
14
+ "Run coord inbox, then coord next --wait. Claim a task, do the work, finish it. Repeat."
15
+ LEAD_PROMPT = "Follow your role skill and its coordination rules as role %<role>s. " \
16
+ "You are a lead role. Never claim a task. Run coord inbox --wait and handle each message."
17
+
18
+ def self.launch(role, _worker, model)
19
+ cmd = %w[hermes chat]
20
+ cmd += ["--skills", HermesSkill.name(role)] if HermesSkill.installed?(role)
21
+ cmd += ["--model", model] if model
22
+ cmd += ["-t", Flow::READ_ONLY_TOOLSETS] if read_only?(role)
23
+ Launcher.exec_or_die(cmd + ["-q", prompt(role)])
24
+ end
25
+
26
+ def self.prompt(role) = format(Flow::LEADS.include?(role) ? LEAD_PROMPT : PROMPT, role: role)
27
+
28
+ def self.read_only?(role)
29
+ Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module SetupAgent
5
+ # HermesSkill resolves the skill flow.rb generated for a role: the name is
6
+ # <project>-<role>, and the dir is the manifest's hermes_dir (set by
7
+ # `flow.rb --hermes-dir`) or flow.rb's default ~/.hermes/skills.
8
+ module HermesSkill
9
+ DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
10
+
11
+ # Plain defs, not endless ones: this file must still parse on Ruby 2.x so
12
+ # the version check above can print its message.
13
+ def self.name(role)
14
+ "#{File.basename(Project.root)}-#{role}"
15
+ end
16
+
17
+ def self.dir
18
+ Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
19
+ end
20
+
21
+ def self.installed?(role)
22
+ File.exist?(File.join(dir, name(role), "SKILL.md"))
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module SetupAgent
5
+ module Launcher
6
+ REGISTRY = {}
7
+
8
+ def self.for(harness)
9
+ register_defaults if REGISTRY.empty?
10
+ REGISTRY.fetch(harness) { abort "setup_agent: no launcher for harness '#{harness}' yet." }
11
+ end
12
+
13
+ def self.register_defaults
14
+ REGISTRY["claude"] = Claude
15
+ REGISTRY["opencode"] = Opencode
16
+ REGISTRY["codex"] = Codex
17
+ REGISTRY["hermes"] = Hermes
18
+ end
19
+
20
+ def self.exec_or_die(cmd)
21
+ Kernel.exec(*cmd)
22
+ rescue Errno::ENOENT
23
+ abort "setup_agent: '#{cmd.first}' not found on PATH."
24
+ end
25
+
26
+ # FlowFiles finds the files of the flow in .maf/ of the main project. The
27
+ # project's own harness config stays as it is: each file of the flow is
28
+ # passed to the harness at the start.
29
+ module FlowFiles
30
+ def self.path(*parts) = File.join(Project.root, ".maf", *parts)
31
+ def self.flag(name, *parts) = File.exist?(path(*parts)) ? [name, path(*parts)] : []
32
+ end
33
+
34
+ # --settings adds the hooks of the flow, --mcp-config adds the graph server.
35
+ # --mcp-config takes many values, so `--` ends the options before the prompt.
36
+ module Claude
37
+ def self.launch(role, _worker, model)
38
+ prompt = "Read .maf/agents/claude/#{role}.md and follow it exactly. Start your work loop now."
39
+ cmd = ["claude", *FlowFiles.flag("--settings", "claude", "settings.json"),
40
+ *FlowFiles.flag("--mcp-config", "mcp", "claude.json")]
41
+ Launcher.exec_or_die(cmd + (model ? ["--model", model] : []) + ["--", prompt])
42
+ end
43
+ end
44
+
45
+ # opencode merges the file in OPENCODE_CONFIG with the project's opencode.json.
46
+ module Opencode
47
+ def self.launch(role, _worker, model)
48
+ config = FlowFiles.path("mcp", "opencode.json")
49
+ ENV["OPENCODE_CONFIG"] = config if File.exist?(config)
50
+ cmd = ["opencode", ".", "--agent", role, "--prompt", "Start your work loop now."]
51
+ Launcher.exec_or_die(cmd + (model ? ["--model", model] : []))
52
+ end
53
+ end
54
+
55
+ module Codex
56
+ def self.launch(role, _worker, model)
57
+ prompt_file = ".codex/prompts/#{role}.md"
58
+ abort "setup_agent: #{prompt_file} missing." unless File.exist?(prompt_file)
59
+
60
+ cmd = ["codex", *writable_dirs]
61
+ cmd += ["--model", model] if model
62
+ warn_untrusted
63
+ Launcher.exec_or_die(cmd + [File.read(prompt_file)])
64
+ end
65
+
66
+ UNTRUSTED = "maf: Codex does not trust the hooks in %<file>s yet. Trust them when Codex asks. " \
67
+ "Without the hooks, the session records no token usage, and coord await cannot wake it."
68
+
69
+ # Codex runs the hooks of a project only after the user trusts them, and
70
+ # a worktree is a new project for Codex. Codex records the trust in its config.toml.
71
+ def self.warn_untrusted
72
+ hooks = File.join(File.realpath(Dir.pwd), ".codex", "hooks.json")
73
+ config = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
74
+ trusted = File.exist?(config) && File.read(config).include?("#{hooks}:stop:")
75
+ warn format(UNTRUSTED, file: hooks) if File.exist?(hooks) && !trusted
76
+ end
77
+
78
+ # The worktree is the only writable root of the Codex sandbox. coord
79
+ # writes the board and the artifacts in .maf/coordination of the main
80
+ # project, so Codex would ask for approval at each coord command.
81
+ def self.writable_dirs = ["--add-dir", File.join(SetupAgent::Project.root, ".maf", "coordination")]
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module SetupAgent
5
+ class Manifest
6
+ def self.load(path)
7
+ abort_missing(path) unless File.exist?(path)
8
+
9
+ new(JSON.parse(File.read(path))["agents"])
10
+ end
11
+
12
+ def self.abort_missing(path)
13
+ abort "setup_agent: #{path} missing. Fix: maf add HARNESS:ROLE"
14
+ end
15
+
16
+ def initialize(agents)
17
+ @agents = agents
18
+ end
19
+
20
+ def verify!(harness, role)
21
+ return if @agents.any? { |a| a["harness"] == harness && a["role"] == role }
22
+
23
+ abort "setup_agent: no #{harness}:#{role} in .maf/config.json.\n#{add_hint("#{harness}:#{role}")}"
24
+ end
25
+
26
+ # maf add keeps the current agents, so the hint names only the missing agent.
27
+ def add_hint(missing) = " Fix: maf add #{missing}"
28
+
29
+ def model_for(harness, role)
30
+ entry = @agents.find { |a| a["harness"] == harness && a["role"] == role }
31
+ entry && entry["model"]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../shared/project"
4
+
5
+ module Maf
6
+ module SetupAgent
7
+ Project = Maf::Shared::Project
8
+ end
9
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module SetupAgent
5
+ # A role file that is not committed yet is missing in a new worktree, and
6
+ # the harness then starts without its role. Copy the main checkout's file.
7
+ # The worktree also needs the symlink from the harness folder to the file.
8
+ # A harness folder with the project's own files gets one link per role file.
9
+ module RoleFile
10
+ def self.copy(root, dir, harness, role)
11
+ relative = Flow.role_path(harness, role)
12
+ return unless relative
13
+
14
+ copy_missing(File.join(root, relative), File.join(dir, relative))
15
+ link(Flow::AgentLinks.new(dir), harness)
16
+ end
17
+
18
+ def self.link(links, harness)
19
+ links.link_files(harness) if links.link(harness) == :refuse
20
+ end
21
+
22
+ def self.copy_missing(source, dest)
23
+ return unless File.exist?(source) && !File.exist?(dest)
24
+
25
+ FileUtils.mkdir_p(File.dirname(dest))
26
+ FileUtils.cp(source, dest)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../bootstrap"
4
+
5
+ module Maf
6
+ module SetupAgent
7
+ # Refresh owned hooks before the harness starts in an existing worktree.
8
+ module RuntimeHooks
9
+ def self.install(dir, harness)
10
+ %w[next-task.rb board-watch.rb session-guard.rb context-watch.rb].each { |name| copy(dir, name) }
11
+ configure(dir, harness)
12
+ copy(dir, "board-watch-opencode.js", ".opencode/plugins/board-watch.js") if harness == "opencode"
13
+ end
14
+
15
+ # Claude Code reads the hooks from the settings file of the main project
16
+ # (`maf start` passes it with --settings). The hook commands run in the
17
+ # worktree, where the copies above live.
18
+ def self.configure(dir, harness)
19
+ root = Project.root
20
+ settings = File.join(root, Bootstrap::CLAUDE_SETTINGS)
21
+ Bootstrap::ClaudeSettings.new(Bootstrap::Project.new(root, false)).configure(settings) if harness == "claude"
22
+ Flow::CodexHooks.new(dir).install if harness == "codex"
23
+ end
24
+
25
+ def self.copy(dir, name, relative = ".maf/coordination/harness-hooks/#{name}")
26
+ path = File.join(dir, relative)
27
+ abort "maf: #{path} is a foreign hook; the worker was not started" if foreign?(path, name)
28
+
29
+ Flow::HookFiles.copy(File.join(Flow::ASSETS, "harness-hooks", name), path)
30
+ end
31
+
32
+ def self.foreign?(path, name)
33
+ File.exist?(path) && !File.read(path).include?("#{name} -")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../shared/project"
4
+
5
+ module Maf
6
+ module SetupAgent
7
+ class Worktree
8
+ COORD = ".maf/bin/coord"
9
+
10
+ # coord creates the worktree with the same shared formula.
11
+ def self.dir_for(root, slug) = Maf::Shared::Project.worktree_dir(root, slug)
12
+
13
+ # `coord worktree` is idempotent. Run it also for an existing worktree,
14
+ # so the worktree gets runtime files that were added after it was created.
15
+ # MAF_HARNESS tells coord which harness starts the worker, so coord checks
16
+ # the role file of that harness only.
17
+ def self.ensure(role, worker_id, harness)
18
+ dir = dir_for(Dir.pwd, "#{role}-#{worker_id}")
19
+ abort "setup_agent: coord worktree failed" unless coord_worktree(role, worker_id, harness)
20
+ new(dir)
21
+ end
22
+
23
+ def self.coord_worktree(role, worker_id, harness)
24
+ system({ "MAF_HARNESS" => harness }, RbConfig.ruby, COORD, "worktree", role, worker_id)
25
+ end
26
+
27
+ def initialize(dir)
28
+ @dir = dir
29
+ end
30
+
31
+ def dir
32
+ @dir
33
+ end
34
+
35
+ def export_env!
36
+ path = File.join(@dir, ".maf/env.sh")
37
+ File.readlines(path).each { |line| set_env(line) } if File.exist?(path)
38
+ ENV["PATH"] = [ENV["MAF_BIN"], ENV["PATH"]].compact.join(File::PATH_SEPARATOR)
39
+ end
40
+
41
+ private
42
+
43
+ def set_env(line)
44
+ return unless line.chomp =~ /\Aexport (\w+)=(.+)\z/
45
+
46
+ ENV[Regexp.last_match(1)] = Regexp.last_match(2)
47
+ end
48
+ end
49
+ end
50
+ end