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,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
|