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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # Installer runs the install: check dependencies, plan the actions, apply
6
+ # them, start the vault, and print the next steps.
7
+ class Installer
8
+ # Each step is [planner, method]. The order is the order of the output.
9
+ PLAN_STEPS = [
10
+ %i[layout dirs], %i[scripts shared], %i[scripts coord], %i[scripts dispatcher], %i[scripts vault],
11
+ %i[scripts dashboard], %i[scripts analyst], %i[scripts env], %i[text taskrc], %i[text exclude],
12
+ %i[scripts hooks], %i[scripts opencode_plugin], %i[claude plan], %i[git commit_guard],
13
+ %i[scripts doc_graph], %i[git doc_graph_hooks]
14
+ ].freeze
15
+ PLANNERS = { layout: LayoutPlanner, scripts: ScriptPlanner, text: TextPlanner, claude: ClaudeSettings,
16
+ git: GitHookPlanner }.freeze
17
+
18
+ def initialize(argv)
19
+ @options = Options.new(argv)
20
+ end
21
+
22
+ def run
23
+ validate_target
24
+ prepare
25
+ actions = plan
26
+ @options.check ? print_plan(actions) : install(actions)
27
+ end
28
+
29
+ private
30
+
31
+ def validate_target
32
+ target = @options.target
33
+ abort "usage: bootstrap.rb /path/to/project [--roles a,b,c] [--check] [--force]" if target.nil?
34
+ abort "target is not a directory: #{target}" unless Dir.exist?(target)
35
+ end
36
+
37
+ def prepare
38
+ @project = Project.new(@options.target, @options.force)
39
+ Dependencies.new(@options).report
40
+ GlobalTaskrcWarning.new(@project).run
41
+ end
42
+
43
+ def install(actions)
44
+ Writer.new(@project).apply(actions)
45
+ GraphHome.new(@project).move
46
+ MemoryBranch.new(@project).setup
47
+ print_next_steps(VaultStarter.new(@project).start(actions))
48
+ end
49
+
50
+ def plan
51
+ planners = PLANNERS.transform_values { |klass| klass.new(@project) }
52
+ PLAN_STEPS.flat_map { |planner, step| planners.fetch(planner).public_send(step) }
53
+ end
54
+
55
+ def print_plan(actions)
56
+ actions.each { |act| Bootstrap.say(@project.format_action(act)) }
57
+ puts "\n--check: no changes made."
58
+ end
59
+
60
+ def print_next_steps(vault_note)
61
+ puts
62
+ puts format(NEXT_STEPS, project: @project.target, roles: @options.roles, vault_note: vault_note)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # LayoutPlanner plans the directories of the .maf/ folder.
6
+ class LayoutPlanner
7
+ def initialize(project)
8
+ @project = project
9
+ end
10
+
11
+ def dirs
12
+ [@project.path(MAF_DIR, "bin"), *SUBDIRS.map { |sub| @project.path(MAF_DIR, "coordination", sub) }].map do |dir|
13
+ @project.action(Dir.exist?(dir) ? :skip : :mkdir, dir)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # MarkedBlock finds the tool-owned block in a file: from the first line that
6
+ # contains MARKER to the next line that contains END_MARKER, inclusive. The
7
+ # comment syntax around the markers differs per file (`#` vs `<!-- -->`), so
8
+ # only the marker text is matched.
9
+ class MarkedBlock
10
+ def initialize(text)
11
+ @lines = text.lines
12
+ end
13
+
14
+ def current?(block)
15
+ range && @lines[range].join.strip == block.strip
16
+ end
17
+
18
+ def replace(block)
19
+ return @lines.join unless range
20
+
21
+ block = "#{block.chomp}\n"
22
+ (@lines[0...range.begin] + [block] + @lines[(range.end + 1)..]).join
23
+ end
24
+
25
+ def remove
26
+ return @lines.join unless range
27
+
28
+ (@lines[0...range.begin] + @lines[(range.end + 1)..]).join
29
+ end
30
+
31
+ private
32
+
33
+ def range
34
+ @range ||= find_range
35
+ end
36
+
37
+ def find_range
38
+ first = @lines.index { |line| line.include?(MARKER) }
39
+ last = first && @lines[first..].index { |line| line.include?(END_MARKER) }
40
+ last && (first..(first + last))
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Maf
6
+ module Bootstrap
7
+ # MemoryBranch keeps the work memory in git with no noise in the project
8
+ # history (ADR 0006). graphify-out/memory/ is a worktree of the orphan branch
9
+ # maf/memory. The branch shares no history with the project branches, so a
10
+ # note never shows in a pull request. A new clone takes the branch from
11
+ # origin. The notes of an older install move into the worktree. When git
12
+ # fails, the folder stays a plain folder and the flow works as before.
13
+ class MemoryBranch
14
+ BRANCH = "maf/memory"
15
+ DIR = File.join(GRAPH_DIR, "memory")
16
+ START = "memory: start the work memory of the flow"
17
+ ADOPT = "memory: add the notes of an older install"
18
+ CONFIG = File.join(MAF_DIR, "config.json")
19
+
20
+ def initialize(project) = @project = project
21
+
22
+ def setup
23
+ return if !repo? || File.exist?(path(DIR, ".git"))
24
+
25
+ done = (local? || fetched? || created?) && checkout
26
+ Bootstrap.say(done ? "done #{DIR} is a worktree of branch #{BRANCH}" : "skip #{BRANCH} (git failed)")
27
+ end
28
+
29
+ private
30
+
31
+ # A project folder inside another repository has no repository of its own.
32
+ def repo? = out("rev-parse", "--show-toplevel") == @project.target
33
+ def local? = git("show-ref", "--verify", "--quiet", "refs/heads/#{BRANCH}")
34
+ def fetched? = git("fetch", "-q", "origin", "#{BRANCH}:#{BRANCH}")
35
+
36
+ # An orphan commit of the empty tree. It needs no checkout.
37
+ def created?
38
+ tree = out("hash-object", "-w", "-t", "tree", File::NULL)
39
+ commit = out("commit-tree", tree, "-m", START)
40
+ !commit.empty? && git("branch", BRANCH, commit)
41
+ end
42
+
43
+ # The notes of an older install wait in a side folder while git adds the worktree.
44
+ def checkout
45
+ old = "#{path(DIR)}.old"
46
+ FileUtils.mv(path(DIR), old) if Dir.exist?(path(DIR))
47
+ FileUtils.mkdir_p(path(GRAPH_DIR))
48
+ git("worktree", "add", "-q", path(DIR), BRANCH).tap { |added| restore(old, added) }
49
+ end
50
+
51
+ def restore(old, added)
52
+ return unless Dir.exist?(old)
53
+
54
+ added ? adopt(old) : FileUtils.mv(old, path(DIR))
55
+ end
56
+
57
+ def adopt(old)
58
+ Dir.children(old).each { |name| FileUtils.mv(File.join(old, name), path(DIR, name)) }
59
+ FileUtils.rm_rf(old)
60
+ git_in(path(DIR), "add", "-A") && git_in(path(DIR), "commit", "-q", "-m", ADOPT)
61
+ end
62
+
63
+ def git(*args) = git_in(@project.target, *args)
64
+ def git_in(dir, *args) = system(identity, "git", "-C", dir, *args, out: File::NULL, err: File::NULL)
65
+
66
+ def out(*args)
67
+ text, status = Open3.capture2(identity, "git", "-C", @project.target, *args, err: File::NULL)
68
+ status.success? ? text.strip : ""
69
+ end
70
+
71
+ # The flow makes these commits, so they use the git persona of the agents.
72
+ def identity = @identity ||= Maf::Shared::GitIdentity.env(Maf::Shared::GitIdentity.read(path(CONFIG)))
73
+
74
+ def path(*parts) = @project.path(*parts)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # Options parses the bootstrap command line.
6
+ class Options
7
+ DEFAULT_ROLES = "architect,backend-developer,frontend-developer,reviewer,tester"
8
+ BANNER = "Usage: bootstrap.rb /path/to/project [--roles a,b,c] [--check] [--install-deps] [--force]"
9
+
10
+ attr_reader :target, :roles, :check, :install_deps, :force
11
+
12
+ def initialize(argv)
13
+ @roles = DEFAULT_ROLES
14
+ @check = @install_deps = @force = false
15
+ parser.parse!(argv)
16
+ @target = argv.first
17
+ end
18
+
19
+ private
20
+
21
+ # Each flag and the block that runs in the Options instance with its value.
22
+ FLAGS = { "--roles LIST" => ->(v) { @roles = v }, "--check" => ->(_) { @check = true },
23
+ "--install-deps" => ->(_) { @install_deps = true }, "--force" => ->(_) { @force = true } }.freeze
24
+
25
+ def parser
26
+ OptionParser.new do |o|
27
+ o.banner = BANNER
28
+ FLAGS.each { |flag, set| o.on(flag) { |value| instance_exec(value, &set) } }
29
+ o.on("-h", "--help") { puts o; exit 0 }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # Project holds the target directory and answers questions about the files
6
+ # in it. Planners and the writer share one Project.
7
+ class Project
8
+ APPEND_FILES = { taskrc: "taskrc.append", post_commit: "git-hooks/post-commit",
9
+ post_merge: "git-hooks/post-merge" }.freeze
10
+
11
+ attr_reader :target, :assets
12
+
13
+ def initialize(target, force)
14
+ @target = File.realpath(target)
15
+ @force = force
16
+ @assets = File.expand_path("../../../assets", __dir__)
17
+ end
18
+
19
+ def path(*parts)
20
+ File.join(@target, *parts)
21
+ end
22
+
23
+ # A script that this tool copies from assets/NAME into .maf/bin/.
24
+ def bin_path(name)
25
+ path(MAF_DIR, "bin", name)
26
+ end
27
+
28
+ def force?
29
+ @force
30
+ end
31
+
32
+ def ours?(file, signature)
33
+ File.read(file).include?(signature)
34
+ end
35
+
36
+ def marked?(file)
37
+ File.exist?(file) && ours?(file, MARKER)
38
+ end
39
+
40
+ def changed_script?(dest, name)
41
+ File.read(dest) != File.read(File.join(@assets, name))
42
+ end
43
+
44
+ # Status of a script that this tool copies from assets/NAME to DEST.
45
+ def script_status(dest, name, signature)
46
+ return :create unless File.exist?(dest)
47
+ return :refuse if File.directory?(dest) || (!ours?(dest, signature) && !force?)
48
+
49
+ ours?(dest, signature) && !changed_script?(dest, name) ? :skip : :update
50
+ end
51
+
52
+ def taskrc_path
53
+ path(MAF_DIR, "coordination", "taskrc")
54
+ end
55
+
56
+ def taskdata_path
57
+ path(MAF_DIR, "coordination", "taskdata")
58
+ end
59
+
60
+ def append_content(source)
61
+ File.read(File.join(@assets, APPEND_FILES.fetch(source)))
62
+ end
63
+
64
+ def action(kind, file, label = file, source = nil)
65
+ { kind: kind, path: file, label: label, source: source }
66
+ end
67
+
68
+ def refuse_label(status, file, text = "exists and is not ours; use --force")
69
+ status == :refuse ? "#{file} (#{text})" : file
70
+ end
71
+
72
+ ACTION_WORDS = { skip: "skip ", refuse: "REFUSE", upgrade_taskrc: "upgrade" }.freeze
73
+
74
+ def format_action(act) = "#{ACTION_WORDS.fetch(act[:kind], act[:kind])} #{act[:label]}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # ScriptPlanner plans every script copied from assets/: the top-level
6
+ # scripts, the harness hooks, the opencode plugin, and the doc-graph
7
+ # refresh. GitHookPlanner plans the git hooks.
8
+ class ScriptPlanner
9
+ HOOKS = [
10
+ ["harness-hooks/next-task.rb", NEXT_TASK_HOOK_SIGNATURE],
11
+ ["harness-hooks/next-task-hermes.sh", NEXT_TASK_HERMES_SIGNATURE],
12
+ ["harness-hooks/board-watch.rb", BOARD_WATCH_SIGNATURE],
13
+ ["harness-hooks/session-guard.rb", SESSION_GUARD_SIGNATURE],
14
+ ["harness-hooks/context-watch.rb", CONTEXT_WATCH_SIGNATURE]
15
+ ].freeze
16
+
17
+ def initialize(project)
18
+ @project = project
19
+ end
20
+
21
+ # The shared library that the scripts load. Each file is planned as a script.
22
+ def shared
23
+ Dir.children(File.join(@project.assets, SHARED_SOURCE)).sort.map do |name|
24
+ script(File.join(SHARED_SOURCE, name), SHARED_SIGNATURE, @project.path(MAF_DIR, SHARED_DIR, name))
25
+ end
26
+ end
27
+
28
+ def coord
29
+ script("coord", COORD_SIGNATURE)
30
+ end
31
+
32
+ def dispatcher
33
+ script("dispatcher", DISPATCHER_SIGNATURE)
34
+ end
35
+
36
+ # The dashboard reads its HTML page from the file next to it.
37
+ def dashboard
38
+ [script("dashboard", DASHBOARD_SIGNATURE), script("dashboard.html", DASHBOARD_PAGE_SIGNATURE)]
39
+ end
40
+
41
+ def analyst
42
+ script("analyst", ANALYST_SIGNATURE)
43
+ end
44
+
45
+ def vault
46
+ script("vault", VAULT_SIGNATURE)
47
+ end
48
+
49
+ # env.sh puts .maf/bin on PATH. A worker runs `source .maf/env.sh`.
50
+ def env
51
+ script("env.sh", ENV_SIGNATURE, @project.path(MAF_DIR, "env.sh"))
52
+ end
53
+
54
+ def hooks
55
+ HOOKS.map { |name, signature| script(name, signature, @project.path(MAF_DIR, "coordination", name)) }
56
+ end
57
+
58
+ # The doc-graph refresh script rebuilds the shared graph after a markdown
59
+ # change. The git hooks start it.
60
+ def doc_graph
61
+ script("doc-graph-refresh", DOC_GRAPH_SIGNATURE)
62
+ end
63
+
64
+ # The opencode plugin wakes an idle opencode session. Like the Claude
65
+ # hooks, it is installed only if the project has a .opencode/ dir.
66
+ def opencode_plugin
67
+ return [] unless Dir.exist?(@project.path(".opencode"))
68
+
69
+ [script("harness-hooks/board-watch-opencode.js", OPENCODE_BOARD_WATCH_SIGNATURE,
70
+ @project.path(OPENCODE_PLUGIN))]
71
+ end
72
+
73
+ private
74
+
75
+ def script(name, signature, dest = @project.bin_path(name))
76
+ status = @project.script_status(dest, name, signature)
77
+ @project.action(status, dest, @project.refuse_label(status, dest), name)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # TextPlanner plans the taskrc and the local git excludes. The flow never
6
+ # writes into a file that git tracks: it is a tool, not a part of the project.
7
+ class TextPlanner
8
+ def initialize(project)
9
+ @project = project
10
+ end
11
+
12
+ def taskrc
13
+ file = @project.taskrc_path
14
+ status = taskrc_status(file)
15
+ label = status == :refuse ? @project.refuse_label(status, file) : "#{file} (Taskwarrior UDAs, project-local)"
16
+ @project.action(status, file, label)
17
+ end
18
+
19
+ # .git/info/exclude hides the flow from git in this clone only.
20
+ def exclude
21
+ file = LocalExclude.exclude_path(@project.target)
22
+ return [] unless file
23
+
24
+ status = (EXCLUDED - LocalExclude.listed(file)).empty? ? :skip : :local_exclude
25
+ [@project.action(status, file, "#{file} (local git excludes)")]
26
+ end
27
+
28
+ private
29
+
30
+ # Never overwrite a taskrc this tool did not create: it may point at a
31
+ # real Taskwarrior database. A marked taskrc from an older install that
32
+ # has no data.location is upgraded in place, not rewritten.
33
+ def taskrc_status(file)
34
+ return :create_taskrc unless File.exist?(file)
35
+ return :create_taskrc if @project.force? && !@project.ours?(file, MARKER)
36
+ return :refuse unless @project.ours?(file, MARKER)
37
+
38
+ File.read(file).match?(/^data\.location=/) ? :skip : :upgrade_taskrc
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # VaultStarter starts the vault watcher once the script is in place, so
6
+ # shared memory is live right after install with no extra step for the
7
+ # common case (graphify already installed). It skips the start when
8
+ # graphify is missing. The printed next steps cover that case.
9
+ class VaultStarter
10
+ def initialize(project)
11
+ @project = project
12
+ end
13
+
14
+ # Returns the note that the next steps print.
15
+ def start(actions)
16
+ return "skipped (VAULT_SKIP is set)" if ENV["VAULT_SKIP"]
17
+ return "run `vault` after bootstrap (graphify is needed only to run it)" unless installed?(actions)
18
+ return "run `vault` once graphify is installed" unless Bootstrap.which("graphify")
19
+
20
+ launch
21
+ end
22
+
23
+ private
24
+
25
+ def script
26
+ @project.bin_path("vault")
27
+ end
28
+
29
+ def installed?(actions)
30
+ actions.any? { |act| act[:path] == script && act[:kind] != :refuse }
31
+ end
32
+
33
+ def launch
34
+ ok = system(script, chdir: @project.target)
35
+ note = ok ? "started (`vault status` / `vault stop`)" : "failed to start; see .maf/coordination/vault.log"
36
+ Bootstrap.say("vault: #{note}")
37
+ note
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maf
4
+ module Bootstrap
5
+ # Writer performs the planned actions. Every writer method takes
6
+ # (path, source) and returns true when it changed something.
7
+ class Writer
8
+ def initialize(project)
9
+ @project = project
10
+ @claude = ClaudeSettings.new(project)
11
+ end
12
+
13
+ def apply(actions)
14
+ actions.each { |act| apply_action(act) }
15
+ end
16
+
17
+ private
18
+
19
+ def apply_action(act)
20
+ writer = WRITERS[act[:kind]]
21
+ return Bootstrap.say(@project.format_action(act)) unless writer
22
+
23
+ send(writer, act[:path], act[:source]) && Bootstrap.say("done #{act[:label]}")
24
+ end
25
+
26
+ def make_dir(dir, _source)
27
+ FileUtils.mkdir_p(dir)
28
+ end
29
+
30
+ def touch_file(file, _source)
31
+ FileUtils.touch(file)
32
+ end
33
+
34
+ def write_script(dest, name)
35
+ FileUtils.mkdir_p(File.dirname(dest))
36
+ FileUtils.cp(File.join(@project.assets, name), dest)
37
+ FileUtils.chmod("+x", dest)
38
+ true
39
+ end
40
+
41
+ def write_taskrc(file, _source = nil)
42
+ FileUtils.mkdir_p([File.dirname(file), @project.taskdata_path])
43
+ File.write(file, "data.location=#{@project.taskdata_path}\n\n#{@project.append_content(:taskrc)}")
44
+ true
45
+ end
46
+
47
+ # Add the missing data.location to a taskrc this tool already owns,
48
+ # without touching the rest of the file. An old marked taskrc without it
49
+ # would otherwise fall back to the user's global ~/.task.
50
+ def upgrade_taskrc(file, _source = nil)
51
+ FileUtils.mkdir_p(@project.taskdata_path)
52
+ separator = File.size(file).zero? ? "" : "\n"
53
+ File.write(file, "#{separator}data.location=#{@project.taskdata_path}\n", mode: "a")
54
+ true
55
+ end
56
+
57
+ def write_exclude(_file, _source = nil)
58
+ LocalExclude.add(@project.target, *EXCLUDED)
59
+ true
60
+ end
61
+
62
+ def merge_hook(file, source) = HookMerger.new(@project).merge(file, source)
63
+
64
+ def configure_claude_settings(file, _source = nil)
65
+ @claude.configure(file)
66
+ end
67
+ end
68
+ end
69
+ end