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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +411 -0
- data/assets/agents-contract.md +80 -0
- data/assets/analyst +240 -0
- data/assets/coord +2936 -0
- data/assets/dashboard +553 -0
- data/assets/dashboard.html +341 -0
- data/assets/dispatcher +1687 -0
- data/assets/doc-graph-refresh +286 -0
- data/assets/env.sh +6 -0
- data/assets/git-hooks/post-commit +7 -0
- data/assets/git-hooks/post-merge +7 -0
- data/assets/git-hooks/pre-commit +32 -0
- data/assets/harness-hooks/board-watch-opencode.js +87 -0
- data/assets/harness-hooks/board-watch.rb +286 -0
- data/assets/harness-hooks/context-watch.rb +268 -0
- data/assets/harness-hooks/next-task-hermes.sh +48 -0
- data/assets/harness-hooks/next-task.rb +97 -0
- data/assets/harness-hooks/session-guard.rb +128 -0
- data/assets/taskrc.append +11 -0
- data/assets/vault +224 -0
- data/assets/worktree-env.example.rb +26 -0
- data/exe/maf +14 -0
- data/install.md +326 -0
- data/lib/maf/bootstrap/claude_settings.rb +55 -0
- data/lib/maf/bootstrap/dependencies.rb +37 -0
- data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
- data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
- data/lib/maf/bootstrap/graph_home.rb +62 -0
- data/lib/maf/bootstrap/hook_merger.rb +53 -0
- data/lib/maf/bootstrap/installer.rb +66 -0
- data/lib/maf/bootstrap/layout_planner.rb +18 -0
- data/lib/maf/bootstrap/marked_block.rb +44 -0
- data/lib/maf/bootstrap/memory_branch.rb +77 -0
- data/lib/maf/bootstrap/options.rb +34 -0
- data/lib/maf/bootstrap/project.rb +77 -0
- data/lib/maf/bootstrap/script_planner.rb +81 -0
- data/lib/maf/bootstrap/text_planner.rb +42 -0
- data/lib/maf/bootstrap/vault_starter.rb +41 -0
- data/lib/maf/bootstrap/writer.rb +69 -0
- data/lib/maf/bootstrap.rb +162 -0
- data/lib/maf/budget.rb +59 -0
- data/lib/maf/cli.rb +135 -0
- data/lib/maf/env_exclude.rb +23 -0
- data/lib/maf/flow/agent_links.rb +79 -0
- data/lib/maf/flow/bootstrapper.rb +36 -0
- data/lib/maf/flow/codex_hooks.rb +50 -0
- data/lib/maf/flow/generator.rb +63 -0
- data/lib/maf/flow/harness_linker.rb +37 -0
- data/lib/maf/flow/hermes_hook.rb +48 -0
- data/lib/maf/flow/hermes_hook_setup.rb +69 -0
- data/lib/maf/flow/hook_files.rb +16 -0
- data/lib/maf/flow/hook_installer.rb +33 -0
- data/lib/maf/flow/legacy_codex_hook.rb +71 -0
- data/lib/maf/flow/manifest.rb +51 -0
- data/lib/maf/flow/mcp_config.rb +72 -0
- data/lib/maf/flow/mcp_installer.rb +45 -0
- data/lib/maf/flow/models.rb +61 -0
- data/lib/maf/flow/options.rb +65 -0
- data/lib/maf/flow/prompt_builder.rb +85 -0
- data/lib/maf/flow/prompt_text.rb +263 -0
- data/lib/maf/flow/report.rb +89 -0
- data/lib/maf/flow/role_catalog.rb +40 -0
- data/lib/maf/flow/role_files.rb +72 -0
- data/lib/maf/flow/role_stub.rb +38 -0
- data/lib/maf/flow/roster.rb +28 -0
- data/lib/maf/flow/validator.rb +38 -0
- data/lib/maf/flow/workflow.rb +28 -0
- data/lib/maf/flow.rb +84 -0
- data/lib/maf/local_exclude.rb +53 -0
- data/lib/maf/menu.rb +101 -0
- data/lib/maf/migrate/moves.rb +44 -0
- data/lib/maf/migrate/rewrites.rb +53 -0
- data/lib/maf/migrate/role_files.rb +35 -0
- data/lib/maf/migrate/runner.rb +66 -0
- data/lib/maf/migrate/worktrees.rb +65 -0
- data/lib/maf/migrate.rb +62 -0
- data/lib/maf/prompt.rb +40 -0
- data/lib/maf/retire.rb +116 -0
- data/lib/maf/role_limits.rb +49 -0
- data/lib/maf/setup_agent/args.rb +57 -0
- data/lib/maf/setup_agent/dispatch.rb +44 -0
- data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
- data/lib/maf/setup_agent/hermes_skill.rb +26 -0
- data/lib/maf/setup_agent/launcher.rb +85 -0
- data/lib/maf/setup_agent/manifest.rb +35 -0
- data/lib/maf/setup_agent/project.rb +9 -0
- data/lib/maf/setup_agent/role_file.rb +30 -0
- data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
- data/lib/maf/setup_agent/worktree.rb +50 -0
- data/lib/maf/setup_agent.rb +111 -0
- data/lib/maf/shared/git_exclude.rb +33 -0
- data/lib/maf/shared/git_identity.rb +41 -0
- data/lib/maf/shared/peak_rate.rb +20 -0
- data/lib/maf/shared/processes.rb +31 -0
- data/lib/maf/shared/project.rb +34 -0
- data/lib/maf/shared/roles.rb +19 -0
- data/lib/maf/team.rb +114 -0
- data/lib/maf/team_command.rb +73 -0
- data/lib/maf/uninstall/claude_settings.rb +40 -0
- data/lib/maf/uninstall/codex_hooks.rb +18 -0
- data/lib/maf/uninstall/commit_guard.rb +16 -0
- data/lib/maf/uninstall/coordination.rb +15 -0
- data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
- data/lib/maf/uninstall/git.rb +13 -0
- data/lib/maf/uninstall/local_files.rb +33 -0
- data/lib/maf/uninstall/manifest.rb +29 -0
- data/lib/maf/uninstall/marked_files.rb +37 -0
- data/lib/maf/uninstall/mcp_entries.rb +43 -0
- data/lib/maf/uninstall/notes.rb +31 -0
- data/lib/maf/uninstall/owned.rb +12 -0
- data/lib/maf/uninstall/role_files.rb +51 -0
- data/lib/maf/uninstall/runner.rb +67 -0
- data/lib/maf/uninstall/scripts.rb +35 -0
- data/lib/maf/uninstall/vault_watcher.rb +21 -0
- data/lib/maf/uninstall/worktrees.rb +30 -0
- data/lib/maf/uninstall.rb +59 -0
- data/lib/maf/untrack.rb +90 -0
- data/lib/maf/version.rb +5 -0
- data/lib/maf/worker_archive.rb +63 -0
- data/lib/maf/worker_control.rb +137 -0
- data/lib/maf/workers.rb +37 -0
- data/lib/maf.rb +5 -0
- data/templates/claude.md.erb +16 -0
- data/templates/codex.md.erb +7 -0
- data/templates/hermes.md.erb +12 -0
- data/templates/opencode.md.erb +24 -0
- data/templates/role-stub.yml.erb +15 -0
- data/templates/roles.yml +289 -0
- data/templates/workflows/panel.md +20 -0
- data/templates/workflows/plan-review.md +9 -0
- data/templates/workflows/simple.md +4 -0
- data/templates/workflows/tdd.md +8 -0
- 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"
|
data/lib/maf/untrack.rb
ADDED
|
@@ -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
|
data/lib/maf/version.rb
ADDED
|
@@ -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
|