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/install.md ADDED
@@ -0,0 +1,326 @@
1
+ # Multi-agent flow - installation instruction
2
+
3
+ This file is an instruction for an AI coding agent. Read the whole file. Then do
4
+ the steps in order.
5
+
6
+ You set up a shared coordination layer for multiple coding agents in one project.
7
+
8
+ ---
9
+
10
+ ## Step 1 - Install the maf command
11
+
12
+ Check the tools. `maf` and the `coord` tool are Ruby scripts.
13
+
14
+ ```sh
15
+ ruby -v
16
+ git --version
17
+ task --version
18
+ ```
19
+
20
+ If Ruby is older than 3.0, stop. Tell the user to install Ruby 3.0 or later.
21
+ If `task` is missing, install it. On macOS, run `brew install task`.
22
+ On Linux, run `sudo apt-get install taskwarrior`.
23
+
24
+ Install the maf gem:
25
+
26
+ ```sh
27
+ gem install maf
28
+ maf version
29
+ ```
30
+
31
+ If `maf version` works already, do not install the gem again.
32
+ If `maf version` fails after the install, add the gem bin folder to `PATH`.
33
+ `gem env` shows the folder under EXECUTABLE DIRECTORY.
34
+
35
+ The flow folder holds the templates. Call that folder `FLOW`:
36
+
37
+ ```sh
38
+ export FLOW="$(dirname "$(dirname "$(gem which maf)")")"
39
+ ls "$FLOW/templates"
40
+ ```
41
+
42
+ If maf runs from a clone of the repository, `FLOW` is the clone folder.
43
+
44
+ ## Step 2 - Ask the user for the project folder
45
+
46
+ Ask: "Which project folder should use the multi-agent flow?"
47
+
48
+ Use the answer as `PROJECT`. The folder must exist and must be a git repository.
49
+ Run all `maf` commands below in `PROJECT`:
50
+
51
+ ```sh
52
+ cd "$PROJECT"
53
+ ```
54
+
55
+ ## Step 3 - Ask the user for harnesses and roles
56
+
57
+ Ask: "Which agent harnesses do you want to use? For example: opencode, Claude
58
+ Code, Codex, Hermes."
59
+
60
+ Show the available roles. Run:
61
+
62
+ ```sh
63
+ maf roles
64
+ ```
65
+
66
+ Tell the user about the `project-manager` role: the user talks to it, it sends
67
+ goals to the architect (`coord goal add`), and the architect reports back to it.
68
+ Recommend it whenever the architect would otherwise take requests directly
69
+ from the user. Ask the user to map roles to harnesses. Example answer:
70
+
71
+ - claude -> project-manager
72
+ - claude -> architect
73
+ - opencode -> backend-developer
74
+ - opencode -> frontend-developer
75
+ - codex -> reviewer
76
+ - hermes -> tester
77
+
78
+ You may run more than one role in one harness. Open one session per role.
79
+
80
+ ## Step 3b - Ask the user for the number of workers
81
+
82
+ Ask: "How many workers do you want for each worker role?"
83
+
84
+ A worker role is a role other than `project-manager` and `architect`.
85
+ Default: one worker per role.
86
+ Remember the answer. Step 7 uses it for the `maf start` commands.
87
+
88
+ ## Step 3c - Ask the user for custom roles
89
+
90
+ Ask: "Do you need a role that is not in the list? Describe it in one sentence."
91
+
92
+ If the user needs no custom role, skip this step.
93
+ If the user needs a custom role, do these steps for each role:
94
+
95
+ 1. Run `maf role add NAME`. The command writes a stub into `.maf/roles.yml`.
96
+ 2. Replace each `TODO` line in the stub. Use the four duty parts: focus,
97
+ checks, done condition, and avoid. Write them in Simplified Technical English.
98
+ 3. Use `NAME` as a role in Step 5.
99
+
100
+ A role in `.maf/roles.yml` with the name of a built-in role replaces the built-in role.
101
+ `maf roles` shows the source of each role.
102
+
103
+ ## Step 3d - Ask the user for the workflow
104
+
105
+ The workflow tells the architect in which order to create tasks.
106
+ Ask: "Which workflow do you want? Choose one, or describe your own in your own words."
107
+
108
+ | Name | Stages |
109
+ |---|---|
110
+ | `simple` | Implement, review, merge. |
111
+ | `plan-review` | Plan, review the plan, implement, review, merge. |
112
+ | `tdd` | Plan, review the plan, write specs, implement, review, merge. |
113
+ | `panel` | Plan, three reviewers attack the plan, implement, review, three reviewers attack the goal diff, merge. |
114
+
115
+ If the user chooses `panel`, add the roles `reviewer`, `skeptic`, and `auditor` in Step 5.
116
+ The three roles review the same plan and the same goal diff. Each role checks a different area.
117
+
118
+ If the user chooses a name, copy the file `FLOW/templates/workflows/<name>.md`
119
+ to `.maf/workflow.md`.
120
+ If the user describes a workflow, write the description to `.maf/workflow.md` as
121
+ stage instructions. Use Simplified Technical English. Write one instruction per
122
+ sentence. Write "Stage N." at the start of each stage. State the gate that ends
123
+ each stage. Do not create tasks for a stage in advance: the architect creates
124
+ the tasks of the next stage after the gate passes.
125
+ If the user wants no workflow, create no `.maf/workflow.md`.
126
+
127
+ Only the architect reads `.maf/workflow.md`. Other roles do not see it.
128
+ Run `maf update` after each later change of the file.
129
+ Step 5 reads `.maf/roles.yml` and `.maf/workflow.md`, so write both before Step 5.
130
+
131
+ ## Step 4 - Ask the user for a model per role
132
+
133
+ Do not choose models yourself. Ask the user.
134
+
135
+ Show the model hint from step 3 for each role. Explain that the hint is only a
136
+ recommendation. The user knows what runs on the machine.
137
+
138
+ The user may skip a model. If skipped, the harness default applies.
139
+
140
+ If the workflow is `panel`, propose a different model family for each of
141
+ `reviewer`, `skeptic`, and `auditor`. Example: Claude, GPT, and Gemini.
142
+ Different models miss different defects. The user makes the final choice.
143
+
144
+ ## Step 5 - Add the agents
145
+
146
+ Give one `HARNESS:ROLE` argument per role. Add one `--model ROLE=MODEL` flag
147
+ per chosen model.
148
+
149
+ Preview first. This writes nothing.
150
+
151
+ ```sh
152
+ maf add claude:project-manager claude:architect \
153
+ opencode:backend-developer opencode:frontend-developer \
154
+ codex:reviewer hermes:tester \
155
+ --model project-manager=claude-opus-5-5 \
156
+ --model architect=claude-opus-5-5 \
157
+ --check
158
+ ```
159
+
160
+ Then run the same command without `--check`.
161
+
162
+ `maf add` does these things:
163
+
164
+ 1. Sets up the coordination layer.
165
+ 2. Writes a role file for each role, in the format of its harness.
166
+ 3. Writes a manifest at `$PROJECT/.maf/config.json`.
167
+ 4. Writes the graphify MCP server into `.maf/mcp/` for Claude Code and opencode.
168
+ `maf start` passes the file to the harness. The project's `.mcp.json` and `opencode.json` stay as they are.
169
+ For Codex and Hermes, `maf add` prints a command. Run it to add the server.
170
+ To turn the server off, set `"mcp": false` in `.maf/config.json`.
171
+
172
+ `maf add` is idempotent. It skips files that are already correct.
173
+
174
+ ## Step 5b - Turn on the Hermes hook (Hermes roles only)
175
+
176
+ Skip this step when no role uses the Hermes harness.
177
+
178
+ `maf add` installs the hook script at `~/.hermes/agent-hooks/next-task.sh`.
179
+ Hermes runs it when a session ends. The hook resumes the session when the role
180
+ has unclaimed tasks.
181
+
182
+ The hook stays inactive until the Hermes config declares it and the user
183
+ approves it. `maf add` prints the commands. Run them in order.
184
+
185
+ ```sh
186
+ hermes config set hooks.on_session_end '[{"command":"<script path>","timeout":30}]'
187
+ hermes chat --oneshot --accept-hooks -q ok
188
+ hermes hooks doctor
189
+ ```
190
+
191
+ Do not edit `~/.hermes/config.yaml` by hand. The file holds comments and markers
192
+ that a rewrite destroys.
193
+
194
+ The first command replaces the whole `on_session_end` list. If the list is not
195
+ empty, read it first with `hermes config get hooks.on_session_end`. Then set the
196
+ list with the existing entries plus the new entry.
197
+
198
+ The second command approves the hook one time. Hermes stores the consent for this
199
+ version of the script. A new script version needs a new approval.
200
+
201
+ Confirm that every check from `hermes hooks doctor` passes. Then continue.
202
+
203
+ ## Step 5c - Set the Gemini key for the doc-graph refresh
204
+
205
+ `maf add` appends a flow block to the `post-commit` and `post-merge` git hooks.
206
+ A markdown commit or merge starts `.maf/bin/doc-graph-refresh` detached.
207
+ The script runs `graphify extract . --backend gemini` and re-exports
208
+ `graphify-out/obsidian/`. It needs `GEMINI_API_KEY`:
209
+
210
+ ```sh
211
+ export GEMINI_API_KEY=<key>
212
+ ```
213
+
214
+ The hook starts no LLM call without the key. It logs a skip in
215
+ `.maf/coordination/doc-graph.log`. The hook never fails a commit.
216
+
217
+ ## Step 6 - Verify
218
+
219
+ ```sh
220
+ cd "$PROJECT"
221
+ source .maf/env.sh
222
+ coord init
223
+ coord status
224
+ maf agents
225
+ ```
226
+
227
+ `source .maf/env.sh` puts `.maf/bin` on `PATH`, so the shell finds `coord`.
228
+
229
+ Check that each role file exists:
230
+
231
+ - opencode: `.opencode/agents/<role>.md`
232
+ - Claude Code: `.claude/agents/<role>.md`
233
+ - Codex: `.codex/prompts/<role>.md`
234
+ - Hermes: `~/.hermes/skills/<project-name>-<role>/SKILL.md` (namespaced by
235
+ project; Hermes skills are global, so this keeps two projects with the
236
+ same role from overwriting each other's skill)
237
+
238
+ ## Step 6b - Do not commit the flow
239
+
240
+ maf is a tool, not a part of the project. It lists `.maf/` and its links in
241
+ `.git/info/exclude`, so `git status` shows no maf file. Do not commit them.
242
+ The project needs at least one commit, because each worker worktree starts from a commit.
243
+
244
+ If `git ls-files .maf` lists files, an older maf version committed them. Run
245
+ `maf untrack`, review `git status`, and ask the user to commit the result.
246
+
247
+ ## Step 7 - Report to the user
248
+
249
+ Report the generated files. Then print one `maf start` command for each worker.
250
+ Use the number of workers from Step 3b. A worker role with N workers gets the
251
+ names `ROLE_1` to `ROLE_N`. A role with one worker may use the bare role name.
252
+ Then give the user these instructions.
253
+
254
+ > For each claude, opencode, or codex agent, open a terminal in the project
255
+ > folder and run:
256
+ >
257
+ > maf start HARNESS ROLE[_WORKER] [model:PROVIDER/MODEL]
258
+ >
259
+ > Example, for this setup:
260
+ >
261
+ > maf start claude project-manager
262
+ > maf start claude architect
263
+ > maf start opencode backend-developer_1
264
+ > maf start opencode frontend-developer_1
265
+ > maf start codex reviewer
266
+ >
267
+ > To run an agent unattended, add `--dispatch`. The agent then starts only
268
+ > when there is work, and it exits when the work is done:
269
+ >
270
+ > maf start hermes tester --dispatch
271
+ >
272
+ > The architect never talks to the user. Run it with `--dispatch` unless the
273
+ > user wants to watch it: `maf start claude architect --dispatch`.
274
+ >
275
+ > This creates (or reuses) a worktree at `.maf/worktrees/<role>-<worker_id>`,
276
+ > sets `COORD_ROLE` and `COORD_WORKER`, and launches the harness there with
277
+ > its role loaded. To run several instances of one role, add a worker suffix:
278
+ > `backend-developer_1`, `backend-developer_2`. Claims are atomic, so they
279
+ > will not collide.
280
+ >
281
+ > Hermes loads the role as a skill (`--skills <project>-<role>`); the skill
282
+ > file at `~/.hermes/skills/<project>-<role>/SKILL.md` must have been generated
283
+ > by `maf add` first.
284
+ >
285
+ > Talk to the project manager session, not the architect. For example: "Build a
286
+ > task tracker app." The project manager sends the goal to the architect. The
287
+ > architect creates tasks. The workers pick them up. The architect reports back
288
+ > to the project manager, and the project manager reports back to you. If no
289
+ > `project-manager` role was set up, talk to the architect session directly
290
+ > instead.
291
+
292
+ ## Domain documentation
293
+
294
+ Do not create `GLOSSARY.md` at install time. The project has no terms yet.
295
+ The project manager creates the first draft when the first term resolves.
296
+ The architect owns `GLOSSARY.md`. A worker that can edit files commits it on the goal branch.
297
+ Tell the user this in the report of Step 7.
298
+ Do not add a `GLOSSARY-MAP.md` unless the project has more than one bounded context.
299
+ ADRs use the decisions folder: `.agent/decisions/` if it exists, else `docs/decisions/`.
300
+
301
+ ## Notes
302
+
303
+ - One writer per path. The task scope defines the paths. `coord add`/`conflicts`
304
+ warns on overlap; roles with `can_edit: false` also get a restricted tool grant
305
+ where the harness supports one (Claude Code, opencode, Hermes), and the git
306
+ `pre-commit` guard refuses their commits.
307
+ - Every generated role file requires Simplified Technical English in
308
+ `coord msg`, `coord annotate`, and task titles: one instruction per
309
+ sentence, active voice, named subject, no idioms.
310
+ - Take the `ollama` lock before a local model generation:
311
+ `coord with-lock ollama -- <command>`.
312
+ - Give each agent its own worktree so file changes never collide:
313
+ `coord worktree <role>` creates `.maf/worktrees/<role>-<worker_id>` (inside the
314
+ project, excluded from git) on branch `worker/<role>-<worker_id>`. In that worktree
315
+ run `source .maf/env.sh` first; it points `COORD_DIR` and `TASKRC` at the
316
+ main project, so every worktree shares one .maf/coordination/ dir and one task
317
+ board. `maf start` does all of this for you.
318
+ - Claude Code does not auto-load `.claude/agents/<role>.md` into an interactive
319
+ session (that file is a subagent definition, used via its Task tool, not the
320
+ session's own persona). `maf start claude <role>` works around this by
321
+ passing an initial prompt that tells the session to read and follow it.
322
+ - The user can add agents later with `maf add HARNESS:ROLE`.
323
+ The user can remove agents with `maf remove HARNESS:ROLE`.
324
+ `maf add` keeps the current agents.
325
+ - Codex and Hermes have no subagent files. Codex gets custom prompts. Hermes gets
326
+ skills, loaded via `--skills <project>-<role>`. Both work the same way in this flow.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # ClaudeSettings plans and applies the Claude Code hooks in the settings
6
+ # file of the flow, .maf/claude/settings.json. `maf start` passes the file
7
+ # with --settings. Claude Code runs these hooks next to the project's own.
8
+ class ClaudeSettings
9
+ def initialize(project)
10
+ @project = project
11
+ end
12
+
13
+ def plan
14
+ file = @project.path(CLAUDE_SETTINGS)
15
+ [@project.action(status(file), file, "#{file} (next-task + board-watch hooks)", :claude_stop_hook)]
16
+ end
17
+
18
+ def configure(file)
19
+ settings = read(file)
20
+ missing = missing_hooks(settings)
21
+ missing.each { |event, hook| add_hook(settings, event, hook) }
22
+ write(file, settings) unless missing.empty?
23
+ !missing.empty?
24
+ end
25
+
26
+ private
27
+
28
+ def status(file)
29
+ File.exist?(file) && missing_hooks(read(file)).empty? ? :skip : :configure_claude_hook
30
+ end
31
+
32
+ def read(file)
33
+ File.exist?(file) ? (JSON.parse(File.read(file)) rescue {}) : {}
34
+ end
35
+
36
+ def missing_hooks(settings)
37
+ CLAUDE_HOOKS.reject { |event, hook| hook_entry?(settings, event, hook["command"]) }
38
+ end
39
+
40
+ def hook_entry?(settings, event, command)
41
+ (settings.dig("hooks", event) || []).any? { |entry| entry.dig("hooks", 0, "command") == command }
42
+ end
43
+
44
+ def add_hook(settings, event, hook)
45
+ settings["hooks"] ||= {}
46
+ (settings["hooks"][event] ||= []) << { "matcher" => "", "hooks" => [hook] }
47
+ end
48
+
49
+ def write(file, data)
50
+ FileUtils.mkdir_p(File.dirname(file))
51
+ File.write(file, JSON.pretty_generate(data))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # Dependencies reports required and optional tools. It installs the
6
+ # required tools only when the options ask for it.
7
+ class Dependencies
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ def report
13
+ REQUIRED_DEPS.each { |bin, meta| report_required(bin, meta) }
14
+ OPTIONAL_DEPS.each { |bin, hint| report_optional(bin, hint) }
15
+ end
16
+
17
+ private
18
+
19
+ def report_required(bin, meta)
20
+ return Bootstrap.say("#{bin}: present") if Bootstrap.which(bin)
21
+ return install(bin, meta) if @options.install_deps && !@options.check
22
+
23
+ Bootstrap.say("#{bin}: MISSING (required - #{meta[:why]}). Re-run with --install-deps or install it yourself.")
24
+ end
25
+
26
+ def install(bin, meta)
27
+ Bootstrap.say("#{bin}: missing -> installing (#{meta[:why]})")
28
+ meta[:install].call || abort("bootstrap: failed to install #{bin}")
29
+ end
30
+
31
+ def report_optional(bin, hint)
32
+ state = Bootstrap.which(bin) ? "present" : "missing (optional - #{hint})"
33
+ Bootstrap.say("#{bin}: #{state}")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # GitHookPlanner plans the git hooks: the commit guard, and the flow block
6
+ # in the post-commit and post-merge hooks.
7
+ class GitHookPlanner
8
+ COMMIT_GUARD = "git-hooks/pre-commit"
9
+ # Hook event name -> the asset source symbol that holds the block.
10
+ DOC_GRAPH_HOOKS = { "post-commit" => :post_commit, "post-merge" => :post_merge }.freeze
11
+
12
+ def initialize(project)
13
+ @project = project
14
+ end
15
+
16
+ # The commit guard blocks commits by roles with can_edit false. A foreign
17
+ # pre-commit hook stays, even with --force: it may run the project checks.
18
+ def commit_guard
19
+ dest = hook_path("pre-commit")
20
+ return [] unless dest
21
+
22
+ status = commit_guard_status(dest)
23
+ [@project.action(status, dest, @project.refuse_label(status, dest, FOREIGN_GUARD), COMMIT_GUARD)]
24
+ end
25
+
26
+ # Append the flow block to the post-commit and post-merge hooks. A foreign
27
+ # hook (graphify installs one) stays; the flow owns only its block.
28
+ def doc_graph_hooks
29
+ DOC_GRAPH_HOOKS.map { |name, source| hook(name, source) }
30
+ end
31
+
32
+ private
33
+
34
+ FOREIGN_GUARD = "a foreign pre-commit hook; the commit guard is off"
35
+
36
+ def commit_guard_status(dest)
37
+ return :create unless File.exist?(dest)
38
+ return :refuse unless @project.ours?(dest, COMMIT_GUARD_SIGNATURE)
39
+
40
+ @project.changed_script?(dest, COMMIT_GUARD) ? :update : :skip
41
+ end
42
+
43
+ def hook(name, source)
44
+ dest = hook_path(name)
45
+ return @project.action(:skip, "git hook #{name} (no git repository)") unless dest
46
+
47
+ @project.action(hook_status(dest, source), dest, dest, source)
48
+ end
49
+
50
+ def hook_status(dest, source) = File.exist?(dest) && block_current?(dest, source) ? :skip : :merge_hook
51
+
52
+ def block_current?(dest, source)
53
+ MarkedBlock.new(File.read(dest)).current?(@project.append_content(source))
54
+ end
55
+
56
+ def hook_path(name)
57
+ hooks = git_hooks_dir
58
+ hooks && File.join(hooks, name)
59
+ end
60
+
61
+ def git_hooks_dir
62
+ cmd = ["git", "-C", @project.target, "rev-parse", "--git-path", "hooks"]
63
+ path = IO.popen(cmd, err: File::NULL, &:read).strip
64
+ $?.success? && !path.empty? ? File.expand_path(path, @project.target) : nil
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # GlobalTaskrcWarning warns when an older install left the UDA block in the
6
+ # user's global ~/.taskrc. Earlier versions of this installer shared one
7
+ # Taskwarrior database across every project. The warning stops old tasks
8
+ # from staying stranded there without notice.
9
+ class GlobalTaskrcWarning
10
+ def initialize(project)
11
+ @project = project
12
+ end
13
+
14
+ def run
15
+ return unless File.exist?(global) && File.read(global).include?(MARKER)
16
+ return if same_file?(global, @project.taskrc_path)
17
+
18
+ target = @project.target
19
+ puts format(MIGRATION_NOTE, taskrc: global, project: target, project_name: File.basename(target))
20
+ end
21
+
22
+ private
23
+
24
+ def global
25
+ ENV.fetch("TASKRC", File.join(Dir.home, ".taskrc"))
26
+ end
27
+
28
+ def same_file?(first, second)
29
+ File.exist?(first) && File.exist?(second) && File.identical?(first, second)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # GraphHome moves the graph of an older install from .maf/graphify-out/ to
6
+ # graphify-out/ at the project root, and the vault from .maf/obsidian/ to
7
+ # graphify-out/obsidian/. The vault watcher writes the graph, so it stops
8
+ # first. VaultStarter starts it again after the move.
9
+ class GraphHome
10
+ OLD_GRAPH = File.join(MAF_DIR, "graphify-out")
11
+ OLD_VAULT = File.join(MAF_DIR, "obsidian")
12
+ VAULT = File.join(GRAPH_DIR, "obsidian")
13
+
14
+ def initialize(project) = @project = project
15
+
16
+ def move
17
+ return unless Dir.exist?(path(OLD_GRAPH)) || Dir.exist?(path(OLD_VAULT))
18
+
19
+ stop_watcher
20
+ graph_moves = movable?(OLD_GRAPH, GRAPH_DIR)
21
+ relocate(OLD_GRAPH, GRAPH_DIR) if graph_moves
22
+ move_vault(graph_moves)
23
+ end
24
+
25
+ private
26
+
27
+ # A graph at the root is newer than the old one, or belongs to the user. It stays.
28
+ def movable?(old, new) = Dir.exist?(path(old)) && !kept?(old, new)
29
+
30
+ # The moved graph folder can hold a stray vault that an agent exported.
31
+ # The old scripts deleted it before each export, so the real vault replaces it.
32
+ def move_vault(graph_moved)
33
+ return unless Dir.exist?(path(OLD_VAULT))
34
+
35
+ FileUtils.rm_rf(path(VAULT)) if graph_moved
36
+ relocate(OLD_VAULT, VAULT) unless kept?(OLD_VAULT, VAULT)
37
+ end
38
+
39
+ # True when NEW exists. The old folder then stays where it is.
40
+ def kept?(old, new)
41
+ return false unless File.exist?(path(new))
42
+
43
+ Bootstrap.say("keep #{old} (#{new} exists)")
44
+ true
45
+ end
46
+
47
+ def relocate(old, new)
48
+ FileUtils.mkdir_p(File.dirname(path(new)))
49
+ FileUtils.mv(path(old), path(new))
50
+ Bootstrap.say("done move #{old} -> #{new}")
51
+ end
52
+
53
+ def stop_watcher
54
+ script = @project.bin_path("vault")
55
+ pid = path(MAF_DIR, "coordination", "vault.pid")
56
+ system(script, "stop", chdir: @project.target, out: File::NULL) if File.exist?(pid) && File.exist?(script)
57
+ end
58
+
59
+ def path(*parts) = @project.path(*parts)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # HookMerger puts the flow block at the top of a git hook. The rest of the
6
+ # hook stays. A hook in another language than sh stays as it is.
7
+ class HookMerger
8
+ SH_SHEBANG = %r{\A#!\s*(?:/usr/bin/env\s+)?(?:\S*/)?(?:ba)?sh(?:\s|\z)}
9
+
10
+ def initialize(project)
11
+ @project = project
12
+ end
13
+
14
+ # Returns true when the hook changed.
15
+ def merge(file, source)
16
+ return skip_foreign_hook(file) if foreign_interpreter?(file)
17
+
18
+ body = File.exist?(file) ? MarkedBlock.new(File.read(file)).remove : "#!/bin/sh\n"
19
+ write_hook(file, prepend_block(body, @project.append_content(source)))
20
+ end
21
+
22
+ private
23
+
24
+ def foreign_interpreter?(file)
25
+ return false unless File.exist?(file)
26
+
27
+ first = File.open(file, &:gets).to_s
28
+ first.start_with?("#!") && !first.match?(SH_SHEBANG)
29
+ end
30
+
31
+ def skip_foreign_hook(file)
32
+ Bootstrap.say("skip #{file}: foreign hook with a non-sh shebang, doc-graph refresh is off")
33
+ false
34
+ end
35
+
36
+ def prepend_block(body, block)
37
+ shebang, rest = split_shebang(body)
38
+ "#{shebang}#{block.chomp}\n#{rest.lstrip}"
39
+ end
40
+
41
+ def split_shebang(body)
42
+ first, *rest = body.lines
43
+ first&.start_with?("#!") ? [first, rest.join] : ["", body]
44
+ end
45
+
46
+ def write_hook(file, text)
47
+ File.write(file, text)
48
+ FileUtils.chmod("+x", file)
49
+ true
50
+ end
51
+ end
52
+ end
53
+ end