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,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ # .maf/config.json lists the generated agents. Hermes skills live outside
6
+ # the project, so only the manifest tells which ones belong to it.
7
+ class Manifest
8
+ def initialize(project)
9
+ @project = project
10
+ @path = File.join(project, ".maf/config.json")
11
+ end
12
+
13
+ def steps = File.exist?(@path) ? [Owned.remove(@path)] : []
14
+
15
+ def hermes_skills
16
+ roles = data.fetch("agents", []).select { |a| a["harness"] == "hermes" }.map { |a| a["role"] }
17
+ roles.map { |role| File.join(hermes_dir, "#{File.basename(@project)}-#{role}", "SKILL.md") }
18
+ end
19
+
20
+ private
21
+
22
+ def hermes_dir = data.fetch("hermes_dir", Flow::DEFAULT_HERMES_DIR)
23
+
24
+ def data
25
+ @data ||= File.exist?(@path) ? (JSON.parse(File.read(@path)) rescue {}) : {}
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ # Removes the marked block from AGENTS.md and .gitignore. Text
6
+ # outside the block stays. A file with nothing left goes. .gitignore keeps
7
+ # the ignore rule for the kept graphify-out/ dir.
8
+ class MarkedFiles
9
+ FILES = %w[AGENTS.md .gitignore].freeze
10
+
11
+ # keep_rules: false drops the ignore rules too. maf untrack excludes .maf/ locally instead.
12
+ def initialize(project, keep_rules: true)
13
+ @project = project
14
+ @keep_rules = keep_rules
15
+ end
16
+
17
+ def steps
18
+ FILES.map { |name| File.join(@project, name) }.select { |path| Owned.marked?(path) }
19
+ .map { |path| Step.new("remove flow block from #{path}", -> { clean(path) }) }
20
+ end
21
+
22
+ private
23
+
24
+ def clean(path)
25
+ text = Bootstrap::MarkedBlock.new(File.read(path)).remove.rstrip
26
+ text = [text, *(kept_rules(path) - text.lines.map(&:strip))].reject(&:empty?).join("\n")
27
+ text.empty? ? FileUtils.rm(path) : File.write(path, "#{text}\n")
28
+ end
29
+
30
+ def kept_rules(path)
31
+ return [] unless @keep_rules && File.basename(path) == ".gitignore"
32
+
33
+ KEPT_DIRS.select { |dir| Dir.exist?(File.join(@project, dir)) }.map { |dir| "#{dir}/" }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ # Removes the graphify server that an older maf wrote into the project's
6
+ # .mcp.json and opencode.json. Today the server lives in .maf/mcp/. A
7
+ # foreign entry and all other settings stay. A file goes only if nothing
8
+ # else is left in it.
9
+ class McpEntries
10
+ PROJECT_FILES = { "claude" => ".mcp.json", "opencode" => "opencode.json" }.freeze
11
+
12
+ def initialize(project)
13
+ @files = Flow::McpConfig.targets.map { |harness, t| [File.join(project, PROJECT_FILES.fetch(harness)), t] }
14
+ end
15
+
16
+ def steps
17
+ @files.filter_map do |path, target|
18
+ next unless File.exist?(path) && ours?(read(path), target)
19
+
20
+ Step.new("remove the graphify server from #{path}", -> { clean(path, target) })
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def read(path)
27
+ data = JSON.parse(File.read(path))
28
+ data.is_a?(Hash) ? data : {}
29
+ rescue JSON::ParserError
30
+ {}
31
+ end
32
+
33
+ def ours?(data, target) = data.dig(target[:key], Flow::McpConfig::NAME) == target[:entry]
34
+
35
+ def clean(path, target)
36
+ data = read(path)
37
+ section = data.fetch(target[:key]).except(Flow::McpConfig::NAME)
38
+ data = section.empty? ? data.except(target[:key]) : data.merge(target[:key] => section)
39
+ data.except("$schema").empty? ? FileUtils.rm(path) : File.write(path, "#{JSON.pretty_generate(data)}\n")
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ # Tells the user what the uninstaller keeps on purpose.
6
+ class Notes
7
+ def initialize(project) = @project = project
8
+
9
+ def lines
10
+ [kept_dirs, branches, global_hooks].compact
11
+ end
12
+
13
+ private
14
+
15
+ def kept_dirs
16
+ dirs = KEPT_DIRS.select { |dir| Dir.exist?(File.join(@project, dir)) }
17
+ "kept #{dirs.map { |d| "#{d}/" }.join(", ")} (costly to rebuild; delete by hand)" if dirs.any?
18
+ end
19
+
20
+ def branches
21
+ list = Git.lines(@project, "branch", "--list", "worker/*", "--format=%(refname:short)")
22
+ "kept branches #{list.join(", ")} (delete with git branch -D)" if list.any?
23
+ end
24
+
25
+ def global_hooks
26
+ hooks = GLOBAL_HOOKS.select { |path| File.exist?(path) }
27
+ "kept global hooks #{hooks.join(", ")} (other projects can use them)" if hooks.any?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ module Owned
6
+ def self.signed?(path, signature) = File.file?(path) && File.read(path).include?(signature)
7
+ def self.marked?(path) = signed?(path, Bootstrap::MARKER)
8
+ def self.prune(dir) = Dir.exist?(dir) && Dir.empty?(dir) && Dir.rmdir(dir)
9
+ def self.remove(path) = Step.new("remove #{path}", -> { FileUtils.rm(path) })
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ # Removes the role files in .maf/agents/ and the symlink of each harness
6
+ # folder that points there.
7
+ class RoleFiles
8
+ DIRS = %w[claude opencode codex].map { |harness| [".maf", "agents", harness] }.freeze
9
+
10
+ def initialize(project, manifest)
11
+ @project = project
12
+ @manifest = manifest
13
+ end
14
+
15
+ def steps
16
+ links.map { |path| Owned.remove(path) } + project_files.map { |path| Owned.remove(path) } +
17
+ @manifest.hermes_skills.select { |path| Owned.marked?(path) }.map { |path| hermes_step(path) }
18
+ end
19
+
20
+ private
21
+
22
+ def project_files
23
+ DIRS.flat_map { |parts| Dir.glob(File.join(@project, *parts, "*.md")) }.select { |path| Owned.marked?(path) }
24
+ end
25
+
26
+ def links = folder_links + file_links
27
+
28
+ def folder_links
29
+ Flow::HARNESS_DIRS.filter_map do |harness, dir|
30
+ path = File.join(@project, dir)
31
+ path if File.symlink?(path) && File.readlink(path) == Flow::AgentLinks.target(harness)
32
+ end
33
+ end
34
+
35
+ # The links of single role files in a harness folder with the user's own files.
36
+ def file_links
37
+ Flow::HARNESS_DIRS.flat_map do |harness, dir|
38
+ Dir.glob(File.join(@project, dir, "*.md")).select { |path| file_link?(path, harness) }
39
+ end
40
+ end
41
+
42
+ def file_link?(path, harness)
43
+ File.symlink?(path) && File.readlink(path) == Flow::AgentLinks.file_target(harness, File.basename(path))
44
+ end
45
+
46
+ def hermes_step(path)
47
+ Step.new("remove #{File.dirname(path)}", -> { FileUtils.rm(path) && Owned.prune(File.dirname(path)) })
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ class Runner
6
+ USAGE = "Usage: maf uninstall [--check] [--yes] [--force]"
7
+
8
+ def initialize(argv)
9
+ @opts = {}
10
+ OptionParser.new(USAGE) { |o| ["--project DIR", "--check", "--yes", "--force"].each { |f| o.on(f) } }
11
+ .parse!(argv, into: @opts)
12
+ end
13
+
14
+ def run
15
+ return old_layout if Migrate.old_layout?(project)
16
+
17
+ steps = plan
18
+ return finish("nothing to remove in #{project}") if steps.empty?
19
+
20
+ steps.each { |step| say(step.label.gsub("#{project}/", "")) }
21
+ @opts[:check] ? finish("check only; nothing removed") : apply(steps)
22
+ end
23
+
24
+ private
25
+
26
+ def project
27
+ abort USAGE unless @opts[:project] && Dir.exist?(@opts[:project])
28
+ @project ||= File.realpath(@opts[:project])
29
+ end
30
+
31
+ # The uninstaller knows only the new layout. --check shows the migration plan instead.
32
+ def old_layout
33
+ abort "uninstall: #{Migrate::HINT}" unless @opts[:check]
34
+ say("this project uses the old layout. Run maf migrate before maf uninstall. maf migrate plans:")
35
+ Migrate::Runner.new(["--project", project, "--check"]).run
36
+ say("after maf migrate, run maf uninstall --check again to see what the uninstaller removes")
37
+ end
38
+
39
+ def plan
40
+ manifest = Manifest.new(project)
41
+ [VaultWatcher.new(project), Worktrees.new(project, @opts[:force]), Scripts.new(project),
42
+ CommitGuard.new(project), DocGraphHooks.new(project), RoleFiles.new(project, manifest),
43
+ ClaudeSettings.new(project), CodexHooks.new(project), McpEntries.new(project),
44
+ MarkedFiles.new(project), LocalFiles.new(project), Coordination.new(project), manifest].flat_map(&:steps)
45
+ end
46
+
47
+ def apply(steps)
48
+ abort "uninstall: cancelled" unless @opts[:yes] || confirmed?
49
+ steps.each(&:run)
50
+ EMPTY_DIRS.each { |parts| Owned.prune(File.join(project, *parts)) }
51
+ finish("removed the multi-agent flow from #{project}")
52
+ end
53
+
54
+ def confirmed?
55
+ print "Remove the items above? [y/N] "
56
+ $stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
57
+ end
58
+
59
+ def finish(message)
60
+ Notes.new(project).lines.each { |line| say(line) }
61
+ say(message)
62
+ end
63
+
64
+ def say(message) = puts("[multi-agent-flow] #{message}")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ class Scripts
6
+ SIGNATURES = { ".maf/bin/coord" => Bootstrap::COORD_SIGNATURE,
7
+ ".maf/bin/dispatcher" => Bootstrap::DISPATCHER_SIGNATURE,
8
+ ".maf/bin/dashboard" => Bootstrap::DASHBOARD_SIGNATURE,
9
+ ".maf/bin/dashboard.html" => Bootstrap::DASHBOARD_PAGE_SIGNATURE,
10
+ ".maf/bin/analyst" => Bootstrap::ANALYST_SIGNATURE,
11
+ ".maf/bin/vault" => Bootstrap::VAULT_SIGNATURE,
12
+ ".maf/bin/doc-graph-refresh" => Bootstrap::DOC_GRAPH_SIGNATURE,
13
+ ".maf/env.sh" => Bootstrap::ENV_SIGNATURE,
14
+ Bootstrap::OPENCODE_PLUGIN => Bootstrap::OPENCODE_BOARD_WATCH_SIGNATURE }.freeze
15
+
16
+ def initialize(project) = @project = project
17
+
18
+ def steps
19
+ candidates.select { |path, signature| Owned.signed?(path, signature) }.map { |path, _| Owned.remove(path) }
20
+ end
21
+
22
+ private
23
+
24
+ def candidates
25
+ SIGNATURES.map { |name, signature| [File.join(@project, name), signature] } + shared
26
+ end
27
+
28
+ # The files of the shared library. A file without the signature stays.
29
+ def shared
30
+ dir = File.join(@project, Bootstrap::MAF_DIR, Bootstrap::SHARED_DIR)
31
+ Dir.glob(File.join(dir, "*.rb")).map { |path| [path, Bootstrap::SHARED_SIGNATURE] }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ # The vault watcher runs detached. Stop it while its script still exists.
6
+ class VaultWatcher
7
+ def initialize(project) = @project = project
8
+
9
+ def steps
10
+ return [] unless File.exist?(File.join(@project, ".maf", "coordination", "vault.pid")) && script
11
+
12
+ [Step.new("stop vault watcher", -> { system(script, "stop", chdir: @project) })]
13
+ end
14
+
15
+ def script
16
+ path = File.join(@project, ".maf", "bin", "vault")
17
+ path if Owned.signed?(path, Bootstrap::VAULT_SIGNATURE)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Uninstall
5
+ class Worktrees
6
+ def initialize(project, force)
7
+ @project = project
8
+ @force = force
9
+ end
10
+
11
+ def steps = paths.map { |dir| Step.new("remove worktree #{dir}", -> { remove(dir) }) }
12
+
13
+ private
14
+
15
+ def paths
16
+ root = File.join(@project, ".maf/worktrees", "")
17
+ Git.lines(@project, "worktree", "list", "--porcelain")
18
+ .filter_map { |line| line[/\Aworktree (.+)/, 1] }.select { |dir| dir.start_with?(root) }
19
+ end
20
+
21
+ # A worktree that an older migrate moved has an env file that git does not exclude.
22
+ def remove(dir)
23
+ EnvExclude.add(dir)
24
+ args = ["worktree", "remove", *("--force" if @force), dir]
25
+ system("git", "-C", @project, *args, err: File::NULL) ||
26
+ puts("kept #{dir} (uncommitted changes; use --force to remove)")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # uninstall.rb - remove the multi-agent flow from a project.
4
+ #
5
+ # maf uninstall runs Uninstall::Runner. Options: --project DIR [--check] [--yes] [--force]
6
+ #
7
+ # Removes only what bootstrap.rb and flow.rb installed. A file must carry the
8
+ # tool signature or marker. A foreign file with the same name stays.
9
+ # Keeps graphify-out/ at the project root: the graph, the Obsidian vault, and
10
+ # the saved notes. Rebuilding the graph costs many agent runs, so the user deletes it by hand.
11
+ # Removes .maf/ if nothing is left in it.
12
+ # Keeps worker/* branches. Keeps dirty worktrees unless --force is given.
13
+ # Keeps the guarded global Hermes hook. Other projects can use the hook.
14
+ # Stops on an old layout. --check then prints the plan of maf migrate.
15
+ require "fileutils"
16
+ require "json"
17
+ require "optparse"
18
+ require_relative "bootstrap"
19
+ require_relative "flow"
20
+ require_relative "env_exclude"
21
+ require_relative "local_exclude"
22
+ require_relative "migrate"
23
+
24
+ module Maf
25
+ module Uninstall
26
+ KEPT_DIRS = %w[graphify-out].freeze
27
+
28
+ # Removed after all steps if empty. Children come before parents.
29
+ EMPTY_DIRS = [%w[.claude agents], %w[.claude], %w[.opencode agents], %w[.opencode plugins], %w[.opencode],
30
+ %w[.codex prompts], %w[.codex], %w[.maf bin], %w[.maf agents claude], %w[.maf agents opencode],
31
+ %w[.maf agents codex], %w[.maf agents], %w[.maf worktrees],
32
+ %w[.maf lib maf shared], %w[.maf lib maf], %w[.maf lib], %w[.maf]].freeze
33
+
34
+ GLOBAL_HOOKS = [File.join(Dir.home, ".hermes", "agent-hooks", "next-task.sh")].freeze
35
+
36
+ # Step is one removal. --check prints the label and runs nothing.
37
+ Step = Struct.new(:label, :work) do
38
+ def run = work.call
39
+ end
40
+ end
41
+ end
42
+
43
+ require_relative "uninstall/owned"
44
+ require_relative "uninstall/git"
45
+ require_relative "uninstall/vault_watcher"
46
+ require_relative "uninstall/worktrees"
47
+ require_relative "uninstall/scripts"
48
+ require_relative "uninstall/commit_guard"
49
+ require_relative "uninstall/doc_graph_hooks"
50
+ require_relative "uninstall/manifest"
51
+ require_relative "uninstall/role_files"
52
+ require_relative "uninstall/claude_settings"
53
+ require_relative "uninstall/codex_hooks"
54
+ require_relative "uninstall/mcp_entries"
55
+ require_relative "uninstall/marked_files"
56
+ require_relative "uninstall/coordination"
57
+ require_relative "uninstall/local_files"
58
+ require_relative "uninstall/notes"
59
+ require_relative "uninstall/runner"
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # untrack.rb - move an install that git tracks to the local layout.
4
+ require "json"
5
+ require_relative "uninstall"
6
+
7
+ module Maf
8
+ # Untrack removes the flow from the project's git and keeps it working:
9
+ # it untracks .maf/ and the harness links (the files stay on disk), removes
10
+ # the flow blocks and entries from the project files, excludes the flow
11
+ # locally, and runs maf update. The user reviews the change and commits it.
12
+ class Untrack
13
+ USAGE = "Usage: maf untrack [--check] [--yes]"
14
+ PATHS = [".maf", ".opencode/agents", ".codex/prompts", Bootstrap::OPENCODE_PLUGIN].freeze
15
+
16
+ def initialize(root, argv)
17
+ @root = root
18
+ @check = argv.include?("--check")
19
+ @yes = argv.include?("--yes")
20
+ end
21
+
22
+ def run
23
+ steps = plan
24
+ return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
25
+
26
+ steps.each { |step| puts " #{step.label}" }
27
+ @check ? puts("--check: no changes made.") : apply(steps)
28
+ end
29
+
30
+ private
31
+
32
+ def plan
33
+ cleaners = [Uninstall::MarkedFiles.new(@root, keep_rules: false), Uninstall::ClaudeSettings.new(@root),
34
+ Uninstall::McpEntries.new(@root)]
35
+ [untrack_step, *cleaners.flat_map(&:steps)].compact
36
+ end
37
+
38
+ def untrack_step
39
+ paths = tracked
40
+ return nil if paths.empty?
41
+
42
+ Uninstall::Step.new("git rm --cached (the files stay): #{paths.join(", ")}", -> { untrack(paths) })
43
+ end
44
+
45
+ def untrack(paths)
46
+ system("git", "-C", @root, "rm", "-r", "-q", "--cached", "--", *paths, exception: true)
47
+ LocalExclude.add(@root, *Bootstrap::EXCLUDED, *(paths - [".maf"]))
48
+ end
49
+
50
+ def apply(steps)
51
+ abort "maf: cancelled" unless @yes || confirmed?
52
+ steps.each(&:run)
53
+ Dir.chdir(@root) { Maf.flow }
54
+ puts "", "The flow left git. Review the change with `git status`, then commit it."
55
+ end
56
+
57
+ def confirmed?
58
+ print "Untrack the items above? [y/N] "
59
+ $stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
60
+ end
61
+
62
+ # The tracked paths of the flow, and each tracked link into .maf/agents/.
63
+ def tracked = (git_files(*PATHS).map { |path| top(path) } + agent_links + codex_hooks).uniq
64
+
65
+ def top(path) = path.start_with?(".maf/") ? ".maf" : path
66
+
67
+ def agent_links
68
+ git_files(".claude/agents").select { |path| File.symlink?(File.join(@root, path)) && link_to_flow?(path) }
69
+ end
70
+
71
+ def link_to_flow?(path) = File.readlink(File.join(@root, path)).include?("#{Bootstrap::MAF_DIR}/agents")
72
+
73
+ # A .codex/hooks.json with only the flow hooks belongs to the flow.
74
+ def codex_hooks
75
+ path = Flow::CodexHooks::FILE
76
+ git_files(path).select { |file| (hook_commands(file) - Flow::CodexHooks::COMMANDS).empty? }
77
+ end
78
+
79
+ def hook_commands(file)
80
+ data = JSON.parse(File.read(File.join(@root, file)))
81
+ entries = data.fetch("hooks", {}).values.flatten
82
+ entries.flat_map { |entry| entry.fetch("hooks", []) }.map { |hook| hook["command"] }
83
+ rescue JSON::ParserError
84
+ [nil]
85
+ end
86
+
87
+ def git_files(*paths) = IO.popen(["git", "-C", @root, "ls-files", "--", *paths], err: File::NULL, &:readlines)
88
+ .map(&:chomp)
89
+ end
90
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Maf
7
+ # WorkerArchive moves the state of a worker into
8
+ # .maf/coordination/archive/workers/<worker>-<time>-<random>/. A new worker
9
+ # with the same id then starts clean: no old session to resume, no old
10
+ # handoff note, no old status or token data on the dashboard.
11
+ class WorkerArchive
12
+ def initialize(root, worker)
13
+ @coord_dir = File.join(root, ".maf", "coordination")
14
+ @worker = worker
15
+ end
16
+
17
+ def run = move_all({ File.join(@coord_dir, "inbox", @worker) => "inbox" }.merge(run_state, session_files))
18
+
19
+ # A new harness cannot resume the session of the old harness, and its
20
+ # token data must not add to the old data. The worker keeps its work: the
21
+ # inbox, the handoff note, and the logs stay.
22
+ def harness_change = move_all(run_state.merge(session_file))
23
+
24
+ private
25
+
26
+ def move_all(paths)
27
+ sources = paths.select { |path, _name| File.exist?(path) }
28
+ destination = archive_dir unless sources.empty?
29
+ sources.each { |path, name| move(path, File.join(destination, name)) }
30
+ end
31
+
32
+ # The token data, the run history, the analyst hints, and the status.
33
+ def run_state
34
+ { File.join(@coord_dir, "usage", "#{@worker}.json") => "usage.json",
35
+ File.join(@coord_dir, "usage", "#{@worker}.runs.jsonl") => "usage.runs.jsonl",
36
+ File.join(@coord_dir, "hints", "#{@worker}.json") => "hints.json",
37
+ File.join(@coord_dir, "status", "#{@worker}.json") => "status.json" }
38
+ end
39
+
40
+ def session_file
41
+ path = File.join(@coord_dir, "sessions", "#{@worker}.session")
42
+ { path => File.join("sessions", File.basename(path)) }
43
+ end
44
+
45
+ # The session id, the handoff note, the logs, and the watcher state.
46
+ def session_files
47
+ Dir.glob(File.join(@coord_dir, "sessions", "#{@worker}.*")).to_h do |path|
48
+ [path, File.join("sessions", File.basename(path))]
49
+ end
50
+ end
51
+
52
+ def move(path, dest)
53
+ FileUtils.mkdir_p(File.dirname(dest))
54
+ FileUtils.mv(path, dest)
55
+ end
56
+
57
+ def archive_dir
58
+ parent = File.join(@coord_dir, "archive", "workers")
59
+ FileUtils.mkdir_p(parent)
60
+ Dir.mktmpdir("#{@worker}-#{Time.now.utc.strftime('%Y%m%dT%H%M%SZ')}-", parent)
61
+ end
62
+ end
63
+ end