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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # Prompt text that the role files embed.
6
+ # Applies to every `coord msg`, `coord annotate`, and task title/scope an
7
+ # agent writes. Generated role files ship standalone (opencode/codex/hermes
8
+ # sessions never see the user's own CLAUDE.md), so the rules are spelled
9
+ # out here instead of referenced.
10
+ STE_RULE = <<~TEXT.strip
11
+ - Write `coord msg`, `coord annotate`, and task titles in Simplified
12
+ Technical English: one instruction per sentence, active voice, name the
13
+ subject, max 20 words per sentence, no idioms.
14
+ TEXT
15
+
16
+ # Current Claude models start subagents readily and verify their own work
17
+ # without a prompt. Each subagent adds cost and time, so keep the use small.
18
+ SUBAGENT_RULE = <<~TEXT.strip
19
+ - Do the work yourself. Start a subagent only for a large, independent
20
+ search that you cannot finish in a few tool calls.
21
+ - Do not use subagents to verify your work.
22
+ TEXT
23
+
24
+ # Harnesses with a watcher that wakes an idle session.
25
+ WAKE_HARNESSES = %w[claude opencode].freeze
26
+ NO_TASK_STOP = "- If no task and no message is available, stop. The board watcher wakes you when work arrives.\n" \
27
+ " Do not wait in a loop: each return of a wait costs one model call."
28
+ NO_TASK_WAIT = <<~TEXT.strip
29
+ - If no task is available, run `coord next --wait --timeout 540`. It returns
30
+ when a task or a message arrives. If it times out, run it again. Do not poll by hand.
31
+ TEXT
32
+ # Codex returns a long tool call to the model about every 30 seconds, so a
33
+ # wait inside a tool call costs one model call per return. `coord await`
34
+ # arms the stop hook, and the hook waits outside the model.
35
+ NO_TASK_AWAIT = <<~TEXT.strip
36
+ - If no task and no message is available, run `coord await`. Then end your turn.
37
+ The stop hook waits for work and wakes you. Do not wait in a loop.
38
+ TEXT
39
+
40
+ # The project manager waits for reports. Each check in a loop sends the
41
+ # whole context again, so the wait must not call the model.
42
+ PM_WAIT = {
43
+ "claude" => "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
44
+ "opencode" => "End your turn to wait for reports. The board watcher wakes you when a message arrives.",
45
+ "codex" => "To wait for reports, run `coord await`. Tell the user that you wait. Then end your turn.\n" \
46
+ " The stop hook wakes you when a message arrives."
47
+ }.freeze
48
+ PM_WAIT_DEFAULT = "Wait for reports with `coord inbox project-manager --wait --timeout 540`."
49
+
50
+ # Every role except the project manager reads the code graph before work.
51
+ # A query output stays in the context for each later call, so the budget is small.
52
+ GRAPH_RULE = <<~TEXT.strip
53
+ - Query the shared knowledge graph when you start a task or plan a goal, not before.
54
+ It finds code and prior decisions faster than grep. Run `vault age` first.
55
+ Then run `graphify query "..." --budget 800`, or use the graphify MCP tools. Never run `graphify export`.
56
+ Put one line in the report of that task or plan: "Graph: fresh", "Graph: stale", or "Graph: missing".
57
+ TEXT
58
+
59
+ # Only Claude Code has ScheduleWakeup. Other harnesses never see this rule.
60
+ CLAUDE_ARCHITECT_RULE = "- Never call ScheduleWakeup with `stop:false` and no `prompt`. " \
61
+ "The call fails. Poll worker status through the task tool instead."
62
+
63
+ # The coordination contract that every role shares. It lives in the role
64
+ # prompt, not in the project's AGENTS.md: only a session that maf starts
65
+ # needs it, and the project keeps no trace of the tool.
66
+ CONTRACT = File.read(File.join(__dir__, "..", "..", "..", "assets", "agents-contract.md"))
67
+ .lines.grep_v(/(>>>|<<<) multi-agent-flow/)
68
+ .join.strip
69
+
70
+ DECISIONS = "the decisions folder: `.agent/decisions/` if it exists, else `docs/decisions/`"
71
+
72
+ # Lead roles cannot commit (ADR 0004). A worker commits their decision records.
73
+ ARCHITECT_DECISIONS = "Record decisions in #{DECISIONS}. " \
74
+ "Create a task for a worker that can edit files. Put the decision text in the task."
75
+ PM_DECISIONS = "Write each decision to `$COORD_DIR/artifacts/<goal>/decision-<name>.md`. You cannot commit. " \
76
+ "Send the path to the architect. The architect has a worker commit it in #{DECISIONS}."
77
+
78
+ WORKER_LOOP = <<~LOOP
79
+ Work loop:
80
+ 1. Read messages: `coord inbox`.
81
+ 2. List unclaimed tasks for your role: `coord next`.
82
+ 3. Claim one: `coord claim <id>`.
83
+ 4. Check out the task branch: `coord start-task <id>`. It starts from the goal branch.
84
+ Read the task spec: `coord show <id>`. Never use raw `task`.
85
+ 5. Do the work. Stay inside the task scope.
86
+ 6. Before any local model generation: `coord with-lock ollama -- <command>`.
87
+ 7. If the task has a goal, merge the goal branch into the task branch: `git merge goal/<goal-short-id>`.
88
+ Then run the task tests. Do not run the merge suite.
89
+ Run each test file and command of the Acceptance field. Put each result in the TESTS line.
90
+ `coord done` refuses a task branch that lacks the goal branch head.
91
+ 8. Commit the work on the task branch. The architect lands the task branch as one squash commit.
92
+ 9. Report. If the task spec has a Report format, use it. Otherwise use:
93
+ coord annotate <id> "STATUS: done or blocked. FILES: <paths>. TESTS: <one-line result>. NOTES: <assumptions or risks>"
94
+ 10. Finish: `coord done <id>`. The command sends a message to the architect.
95
+
96
+ Rules:
97
+ - You are one worker in a role pool. COORD_WORKER identifies you.
98
+ - One writer per path. Never edit outside the task scope.
99
+ - Do not create tasks. Ask the architect: `coord msg --from %{role} architect "<text>"`.
100
+ - If a problem is outside your task and you cannot fix it, escalate: `coord escalate --task <id> "<text>"`.
101
+ Examples: a missing tool, no access, a refused guard, rules that contradict.
102
+ The project manager asks the user and answers you. Then stop. Do not retry.
103
+ - Before you continue a claimed task after a pause, run `coord show <id>`. If the status is not pending,
104
+ or the worker is not you, stop work on that task. `coord reap` releases the claims of stalled workers.
105
+ - Finish the whole task. Report done only when each acceptance criterion passes.
106
+ - If you cannot finish, do the parts you can. Keep the claim, so another worker
107
+ does not hit the same blocker. Annotate the blocker and the missing parts.
108
+ Message the architect. Stop. Do not retry a failing approach.
109
+ %{no_task_instruction}
110
+ - Record durable knowledge in the shared vault or #{DECISIONS}.
111
+ - Never write ad-hoc verification scripts. The test suite is the verification.
112
+ #{GRAPH_RULE}
113
+ #{STE_RULE}
114
+ #{SUBAGENT_RULE}
115
+ LOOP
116
+
117
+ # Steps 2 to 9 are the same with and without a project manager.
118
+ ARCHITECT_GOAL_STEPS = <<~TEXT.strip
119
+ 2. Decompose the goal into tasks. Keep scopes disjoint (one writer per path).
120
+ Compare the plan with the other open goals: `coord goal list`. List the shared schema and the shared files.
121
+ If two goals need the same change (for example one migration), move it into a small foundation goal.
122
+ The foundation goal merges first. The other goals start after it.
123
+ 3. Create each task with the goal id, then add its spec:
124
+ coord add --role <role> --scope "<paths>" --goal <goal-id> --title "<title>"
125
+ coord annotate <id> "Goal: <goal>. Inputs: <files or context>. Out of scope: <paths or work>. Acceptance: <exact test files or commands that must pass>. Report format: <what to annotate>."
126
+ 4. Watch progress: `coord goal show <goal-id>`, `coord conflicts`, `coord inbox architect`.
127
+ Each done task sends you a message. A task for a role without a worker alerts the project manager.
128
+ 5. Answer worker questions. Resolve conflicts.
129
+ 6. Before you trust a done task, inspect its diff and its TESTS line:
130
+ `git diff goal/<goal-short-id>...task/<task-short-id>`. Do not rerun the task tests.
131
+ If something is wrong, open a new task for the fix. Name the old task branch in Inputs.
132
+ Save the reason: `coord lesson <task-id> dead_end "<what failed>"` or `corrected "<the right way>"`.
133
+ For a done review task, open the file and line of each critical finding before you act.
134
+ If the cited file does not exist, drop the finding. A file that the plan creates is not a reason to drop.
135
+ Open fix tasks only for critical findings. Put warnings and minor findings into later task specs as notes.
136
+ If two reviews contradict, put the finding that you followed and the reason in your goal report.
137
+ 7. Land each accepted task: `coord land <task-id> --subject "<type>(<area>): <summary>"`.
138
+ The command squashes the task branch into the goal branch as one commit and deletes the task branch.
139
+ Use a Conventional Commits subject. If the command reports a conflict, open a fix task.
140
+ 8. When every task of the goal is landed, run `coord goal sync <goal-id>`. It merges the base branch into the goal.
141
+ If the sync conflicts, open a fix task. Then check the graph with `vault age`. Put its state in your report.
142
+ Then run the merge suite one time in the goal worktree:
143
+ `coord with-lock system-test -- <merge suite command>`. Source its `.maf/env.sh` first.
144
+ 9. If `.maf/config.json` has a `github` section, run `coord goal pr <goal-id>`. Keep the goal open.
145
+ The command pushes the goal branch and opens the pull request for the user's review.
146
+ Each review arrives as a message. Fix each point with a fix task. Land it. Then run `coord goal pr` again.
147
+ After the merge, coord closes the goal and runs `coord gc --yes`.
148
+ Without a `github` section, close the goal: `coord goal done <goal-id>`. The user opens the pull request
149
+ from goal/<goal-short-id> into the base branch. After the merge, run `coord gc --yes`.
150
+ A goal pull request uses a merge commit, never a squash.
151
+ TEXT
152
+
153
+ ARCHITECT_RULES = <<~TEXT.strip
154
+ - Never edit files directly. Dispatch work.
155
+ - Change a goal branch only with `coord land` and `coord goal sync`. Never run `git merge` or `git commit` on it.
156
+ - Start each goal from the base branch. Never start a goal from another goal branch.
157
+ - Take the `ollama` lock only if you run a local model yourself.
158
+ - Hand work between stages with artifacts: `$COORD_DIR/artifacts/<goal>/<name>.md`.
159
+ Never use a path inside a worktree.
160
+ - Do not commit. For a durable artifact (an approved spec, an ADR, `GLOSSARY.md`), create a task
161
+ for a worker that can edit files. That worker commits the artifact on the goal branch.
162
+ - Create tasks in stage order. Do not create the task of the next stage until the gate of the current stage passes.
163
+ A task that does not exist cannot be claimed.
164
+ - Do not send a message only to acknowledge. For a note that needs no action now, use `coord msg --fyi`.
165
+ - Before you revise an artifact that a reviewer read, copy it to `<name>.r<round>.md`. Name both files
166
+ in the review task. The reviewer reads `diff -u` of the two files and checks the open findings only.
167
+ #{GRAPH_RULE}
168
+ TEXT
169
+
170
+ # The architect takes goals from the project manager when that role exists,
171
+ # and takes requests from the user directly when it does not. Two variants so
172
+ # the generated file never points at a role nobody runs.
173
+ ARCHITECT_LOOP_PM = <<~LOOP
174
+ Work loop:
175
+ 1. Read goals from the project manager: `coord inbox architect`. Each goal message names a goal id.
176
+ #{ARCHITECT_GOAL_STEPS}
177
+ 10. Report back: `coord msg --from architect project-manager "<summary>"`.
178
+ 11. #{ARCHITECT_DECISIONS}
179
+ 12. Use `coord broadcast --from architect "<text>"` for notices to workers.
180
+ Add `--to all` only for a change that the project manager must know. Use `coord log` to see what happened.
181
+
182
+ Available roles:
183
+ %{roles}
184
+
185
+ Rules:
186
+ #{ARCHITECT_RULES}
187
+ - Take goals only from the project manager. Never take requests directly from the user.
188
+ #{STE_RULE}
189
+ #{SUBAGENT_RULE}
190
+ LOOP
191
+
192
+ ARCHITECT_LOOP_DIRECT = <<~LOOP
193
+ Work loop:
194
+ 1. Read the user's request from this session. Create a goal for it:
195
+ `coord goal add --title "<outcome>"`. The command prints the goal id.
196
+ #{ARCHITECT_GOAL_STEPS}
197
+ 10. Report the outcome to the user in this session.
198
+ 11. #{ARCHITECT_DECISIONS}
199
+ 12. Use `coord broadcast --from architect "<text>"` for notices to workers.
200
+ Use `coord log` to see what happened.
201
+
202
+ Available roles:
203
+ %{roles}
204
+
205
+ Rules:
206
+ #{ARCHITECT_RULES}
207
+ - Take requests from the user directly. This project has no project manager.
208
+ #{STE_RULE}
209
+ #{SUBAGENT_RULE}
210
+ LOOP
211
+
212
+ # The user can give the project manager a budget (`maf team set`) and let it
213
+ # run the team. Dispatched workers cost no tokens while idle, so the rules
214
+ # scale on backlog, not on cost.
215
+ TEAM_RULES = <<~TEXT.strip
216
+ - If the user gives you a team budget, record it: `maf team set --max <n> --allow <harness[:model]> ...`.
217
+ Then manage the team yourself in dispatch mode. `maf prepare ... --dispatch` starts the worker in the background.
218
+ - Check the team with `maf team`. It shows the budget, each worker, and the tasks by role.
219
+ - Staff the architect first, with `--dispatch`. A goal needs the architect before any other worker.
220
+ A dispatched architect starts on each message. An idle interactive architect reads nothing.
221
+ - Check who runs with `coord who`. A role without a live worker does not read its messages.
222
+ - If coord reports "No worker runs role <role>", add a worker for that role.
223
+ - If a role has more than three backlog tasks and the budget has a free slot, add a worker for that role.
224
+ - If a role has no tasks and no open goal needs it, retire its extra workers.
225
+ Keep one worker per role that an open goal needs.
226
+ - If the budget is full, replace an idle worker: `--replace <idle-worker>`.
227
+ - Report each team change to the user in one line.
228
+ TEXT
229
+
230
+ PM_LOOP = <<~LOOP
231
+ Work loop:
232
+ 1. Read the user's request. Interview the user, as the duties describe.
233
+ 2. Turn the request into one goal. Create the goal at the start of the interview:
234
+ `coord goal add --title "<outcome>"`.
235
+ The command prints the goal id and creates the goal branch. The glossary draft path needs the goal id.
236
+ 3. When the interview ends, hand the goal to the architect:
237
+ coord msg --from project-manager architect "GOAL <goal-id>: <goal>"
238
+ 4. Check status with `coord goal list` and `coord goal show <goal-id>` when the user asks.
239
+ %{wait_instruction}
240
+ Never poll with sleep or with repeated status commands. Each check sends your whole context again.
241
+ 5. Summarize the report for the user.
242
+ 6. #{PM_DECISIONS}
243
+
244
+ Rules:
245
+ - Never edit source files. Never create tasks; only the architect creates tasks.
246
+ - Change the team when the user asks. Do not ask the user to run setup steps.
247
+ Add a worker: `maf prepare <harness> <role>_<n>`.
248
+ Replace a worker: `maf prepare <harness> <role>_<n> --replace <old-role>_<n>`.
249
+ Remove a worker: `maf retire <role>_<n>`.
250
+ Give the user the two commands that `maf prepare` prints: `cd <worktree>` and `maf start`.
251
+ If `maf` reports that the old worker still runs, ask the user to stop that session. Then run the command again.
252
+ #{TEAM_RULES}
253
+ - Send goals to the architect only. Never dispatch work to other roles directly.
254
+ - Send the user's decisions to the architect in one message, not one message per decision.
255
+ Each message starts an architect run. For a note that needs no action now, use `coord msg --fyi`.
256
+ - Do not read the project docs in full. Each file that you read stays in your context for the whole session.
257
+ Ask the architect, or start one subagent that returns a summary of at most 300 words.
258
+ - If no report has arrived yet, tell the user and check again with `coord inbox project-manager`.
259
+ #{STE_RULE}
260
+ #{SUBAGENT_RULE}
261
+ LOOP
262
+ end
263
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # Report prints the result of a run and the next steps for the user.
6
+ class Report
7
+ def initialize(project, roles, hermes_pending)
8
+ @project = project
9
+ @roles = roles
10
+ @hermes_pending = hermes_pending
11
+ end
12
+
13
+ def print(results)
14
+ puts "", "Generated role files:", results.map { |result| generated_line(result) }
15
+ %i[print_missing_models print_unknown_models print_unembeddable print_sessions]
16
+ .each { |name| send(name, results) }
17
+ print_hermes_reminder
18
+ end
19
+
20
+ private
21
+
22
+ # The hook steps print at install time, far above the last lines the user
23
+ # reads. Repeat the state here so the flow is not started with a dead hook.
24
+ def print_hermes_reminder
25
+ return unless @hermes_pending
26
+
27
+ puts "The Hermes hook is not active yet. Finish the steps above.",
28
+ "Then run `maf update` to confirm the hook is ready."
29
+ end
30
+
31
+ def generated_line(result)
32
+ " #{result[:status].to_s.ljust(7)} #{result[:agent][:harness]}:" \
33
+ "#{result[:agent][:role]} -> #{result[:dest]}"
34
+ end
35
+
36
+ def print_missing_models(results)
37
+ missing = results.reject { |result| result[:model] }.map { |result| " #{model_hint(result)}" }
38
+ return if missing.empty?
39
+
40
+ puts "", "No model chosen for these roles. maf leaves the choice to you. Suggestions:", missing,
41
+ " Set one with: --model <role>=<provider/model>"
42
+ end
43
+
44
+ # A dispatched run with an unknown model fails before it reaches the model.
45
+ def print_unknown_models(results)
46
+ unknown = results.select { |r| r[:model] && !Models.known?(r[:agent][:harness], r[:model]) }
47
+ return if unknown.empty?
48
+
49
+ puts "", "WARNING: the harness does not list these models. Check the name for a typo:",
50
+ unknown.map { |r| " #{r[:agent][:harness]}:#{r[:agent][:role]} -> #{r[:model]}" },
51
+ " Fix one with: maf add HARNESS:ROLE:MODEL"
52
+ end
53
+
54
+ def model_hint(result)
55
+ role = result[:agent][:role]
56
+ "#{role}: #{@roles.fetch(role).fetch("model_hint")}"
57
+ end
58
+
59
+ PASTE_HINT = %( "Run coord inbox <role>. Then work the pending tasks assigned to you. Repeat.")
60
+
61
+ def print_sessions(results)
62
+ puts "", "Next: start one session per agent in #{@project}:",
63
+ results.map { |result| " maf start #{result[:agent][:harness]} #{result[:agent][:role]}" },
64
+ "", "In each worker session, paste:",
65
+ PASTE_HINT, entry_point_hint(results)
66
+ end
67
+
68
+ def entry_point_hint(results)
69
+ return "In the architect session, describe what you want built." if results.none? { |r| pm?(r) }
70
+
71
+ "Talk to the project manager session. It hands goals to the architect."
72
+ end
73
+
74
+ def pm?(result) = result[:agent][:role] == "project-manager"
75
+
76
+ def print_unembeddable(results)
77
+ list = results.select { |r| r[:model] && %w[codex hermes].include?(r[:agent][:harness]) }
78
+ return if list.empty?
79
+
80
+ puts "", "These harnesses cannot embed a model in the role file. Set it in the harness:",
81
+ list.map { |r| " #{r[:agent][:harness]}:#{r[:agent][:role]} -> #{model_command(r)}" }
82
+ end
83
+
84
+ def model_command(result)
85
+ result[:agent][:harness] == "codex" ? "codex -m #{result[:model]}" : "hermes model"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # RoleCatalog merges the built-in roles with the project roles. A project
6
+ # role in .maf/roles.yml adds a role or replaces a built-in role.
7
+ class RoleCatalog
8
+ FILE = ".maf/roles.yml"
9
+ DEFAULTS = { "model_hint" => "", "can_edit" => true }.freeze
10
+
11
+ def initialize(project)
12
+ @project = project
13
+ end
14
+
15
+ def roles
16
+ @roles ||= builtin.merge(custom.transform_values { |data| DEFAULTS.merge(data) })
17
+ end
18
+
19
+ def source(name)
20
+ return "project" if custom.key?(name)
21
+
22
+ "built-in"
23
+ end
24
+
25
+ def custom
26
+ @custom ||= File.exist?(path) ? load_file(path) : {}
27
+ end
28
+
29
+ def path = File.join(@project, FILE)
30
+
31
+ private
32
+
33
+ def builtin = load_file(File.join(TEMPLATES, "roles.yml"))
34
+
35
+ def load_file(file)
36
+ YAML.safe_load_file(file).fetch("roles", nil) || {}
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # RoleFiles renders and writes the role file of each agent.
6
+ class RoleFiles
7
+ MARKER = ">>> multi-agent-flow >>>"
8
+
9
+ def initialize(options, roles)
10
+ @options = options
11
+ @roles = roles
12
+ @prompts = PromptBuilder.new(roles, options.agents, Workflow.new(options.project).block)
13
+ end
14
+
15
+ def generate
16
+ @options.agents.map { |agent| generate_agent(agent) }
17
+ end
18
+
19
+ private
20
+
21
+ def generate_agent(agent)
22
+ role = agent[:role]
23
+ model = @options.model_for(agent)
24
+ dest = destination(agent[:harness], role)
25
+ content = render(agent[:harness], role, @roles.fetch(role), model)
26
+ { agent: agent, dest: dest, status: write(dest, content), model: model }
27
+ end
28
+
29
+ # Hermes skills live in a global ~/.hermes/skills/ directory, not the
30
+ # project. Namespace by project so two projects using the same role
31
+ # don't overwrite each other's skill.
32
+ def destination(harness, role)
33
+ return hermes_path(role) if harness == "hermes"
34
+
35
+ File.join(@options.project, Flow.role_path(harness, role)) if %w[opencode claude codex].include?(harness)
36
+ end
37
+
38
+ def hermes_path(role)
39
+ File.join(@options.hermes_dir, "#{File.basename(@options.project)}-#{role}", "SKILL.md")
40
+ end
41
+
42
+ def render(harness, role, data, model)
43
+ template = File.read(File.join(TEMPLATES, "#{harness}.md.erb"))
44
+ values = { prompt: @prompts.build(harness, role, data), model: model }
45
+ ERB.new(template, trim_mode: "-").result_with_hash(**template_values(role, data), **values)
46
+ end
47
+
48
+ def template_values(role, data)
49
+ { role: role, title: data.fetch("title"), description: data.fetch("description"),
50
+ can_edit: data.fetch("can_edit"), project: File.expand_path(@options.project) }
51
+ end
52
+
53
+ def write(dest, content)
54
+ existed = File.exist?(dest)
55
+ return :skip if existed && File.read(dest) == content
56
+ return :refuse if existed && refuse?(dest)
57
+
58
+ @options.check? ? :check : save(dest, content, existed)
59
+ end
60
+
61
+ def save(dest, content, existed)
62
+ FileUtils.mkdir_p(File.dirname(dest))
63
+ File.write(dest, content)
64
+ existed ? :update : :create
65
+ end
66
+
67
+ def refuse?(dest)
68
+ !File.read(dest).include?(MARKER) && !@options.force?
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # RoleStub adds a stub role to .maf/roles.yml. The stub holds the four duty
6
+ # parts. A role that exists already stays as it is.
7
+ class RoleStub
8
+ TEMPLATE = File.join(TEMPLATES, "role-stub.yml.erb")
9
+
10
+ def initialize(project, name)
11
+ @catalog = RoleCatalog.new(project)
12
+ @name = name
13
+ end
14
+
15
+ # Returns :create or :skip.
16
+ def add
17
+ abort "flow: invalid role name '#{@name}' (use a-z, 0-9, hyphen)" unless @name.match?(/\A[a-z][a-z0-9-]*\z/)
18
+ return :skip if @catalog.custom.key?(@name)
19
+
20
+ write
21
+ :create
22
+ end
23
+
24
+ private
25
+
26
+ def write
27
+ FileUtils.mkdir_p(File.dirname(@catalog.path))
28
+ File.write(@catalog.path, existing + entry)
29
+ end
30
+
31
+ def existing
32
+ @catalog.custom.empty? ? "roles:\n" : File.read(@catalog.path).sub(/\n*\z/, "\n\n")
33
+ end
34
+
35
+ def entry = ERB.new(File.read(TEMPLATE), trim_mode: "-").result_with_hash(name: @name)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # Roster merges the agents saved in .maf/config.json with the --agent and
6
+ # --remove specs of this run. A saved model ranks below --model.
7
+ class Roster
8
+ def initialize(project)
9
+ path = File.join(project, ".maf/config.json")
10
+ @saved = File.exist?(path) ? JSON.parse(File.read(path)).fetch("agents", []) : []
11
+ end
12
+
13
+ def merge(added, removed)
14
+ kept = saved.map { |a| added.find { |b| same?(a, b) }&.merge(saved_model: a[:saved_model]) || a }
15
+ agents = kept + added.reject { |b| kept.any? { |a| same?(a, b) } }
16
+ agents.reject { |a| removed.any? { |b| same?(a, b) } }
17
+ end
18
+
19
+ private
20
+
21
+ def saved
22
+ @saved.map { |a| { harness: a["harness"], role: a["role"], saved_model: a["model"] } }
23
+ end
24
+
25
+ def same?(one, other) = one[:harness] == other[:harness] && one[:role] == other[:role]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # Validator checks the project, the harnesses, and the roles of one run.
6
+ class Validator
7
+ def self.project!(options)
8
+ project = options.project
9
+ abort "flow: --project is required" if project.nil?
10
+ abort "flow: project is not a directory: #{project}" unless Dir.exist?(project)
11
+
12
+ options.project = File.realpath(project)
13
+ end
14
+
15
+ def initialize(options, roles)
16
+ @options = options
17
+ @roles = roles
18
+ end
19
+
20
+ # A removal may leave no agents. Only a run that removes nothing needs one.
21
+ def run
22
+ @options.agents = Roster.new(@options.project).merge(@options.agents, @options.removed)
23
+ abort "flow: no agents. Add one with --agent HARNESS:ROLE" if @options.agents.empty? && @options.removed.empty?
24
+
25
+ @options.agents.each { |agent| check(agent) }
26
+ end
27
+
28
+ private
29
+
30
+ def check(agent)
31
+ abort "flow: unknown harness '#{agent[:harness]}' (use #{HARNESSES.join(", ")})" unless known?(agent)
32
+ abort "flow: unknown role '#{agent[:role]}'" unless @roles.key?(agent[:role])
33
+ end
34
+
35
+ def known?(agent) = HARNESSES.include?(agent[:harness])
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Flow
5
+ # Workflow reads the stage instructions of a project from .maf/workflow.md.
6
+ # Only the orchestrator prompt receives the text.
7
+ class Workflow
8
+ FILE = ".maf/workflow.md"
9
+ HEADING = "Workflow:"
10
+
11
+ def initialize(project)
12
+ @path = File.join(project, FILE)
13
+ end
14
+
15
+ # The prompt block, or nil if the project has no workflow.
16
+ def block
17
+ return nil unless File.exist?(@path)
18
+
19
+ text = File.read(@path).strip
20
+ text.empty? ? nil : "#{HEADING}\n#{indent(text)}"
21
+ end
22
+
23
+ private
24
+
25
+ def indent(text) = text.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
26
+ end
27
+ end
28
+ end