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,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# session-guard.rb - authorize hooks for a registered MAF session.
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module MafSession
|
|
9
|
+
def self.register(root, harness, env = ENV)
|
|
10
|
+
token = SecureRandom.hex(32)
|
|
11
|
+
record = registration(root, harness, env)
|
|
12
|
+
save(record, token)
|
|
13
|
+
env.merge!("MAF_SESSION_TOKEN" => token, "COORD_SESSION_PID" => Process.pid.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.registration(root, harness, env)
|
|
17
|
+
{ "root" => File.realpath(root), "dir" => File.realpath(Dir.pwd), "pid" => Process.pid,
|
|
18
|
+
"harness" => harness, "role" => env.fetch("COORD_ROLE"), "worker" => env.fetch("COORD_WORKER") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.path(root, token)
|
|
22
|
+
File.join(root, ".maf", "coordination", "sessions", "#{token}.maf.json")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.save(record, token)
|
|
26
|
+
path = path(record.fetch("root"), token)
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
28
|
+
File.write(path, JSON.generate(record), mode: "w", perm: 0o600)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Process ownership prevents sibling sessions from using inherited tokens.
|
|
32
|
+
module ProcessOwner
|
|
33
|
+
def self.ancestor?(owner)
|
|
34
|
+
return true if [Process.pid, Process.ppid].include?(owner)
|
|
35
|
+
|
|
36
|
+
includes?(Process.ppid, owner, parents)
|
|
37
|
+
rescue SystemCallError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.parents
|
|
42
|
+
IO.popen(%w[ps -eo pid=,ppid=], err: File::NULL, &:read)
|
|
43
|
+
.lines.to_h { |line| line.split.map(&:to_i) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.includes?(pid, owner, parents)
|
|
47
|
+
return true if pid == owner
|
|
48
|
+
return false if pid <= 1 || !parents.key?(pid)
|
|
49
|
+
|
|
50
|
+
includes?(parents.fetch(pid), owner, parents)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Guard
|
|
55
|
+
attr_reader :coord_dir, :taskrc, :coord
|
|
56
|
+
|
|
57
|
+
def initialize(env, input = {})
|
|
58
|
+
@env, @input = env, input.is_a?(Hash) ? input : {}
|
|
59
|
+
@record = record
|
|
60
|
+
@coord_dir = @record && File.join(@record.fetch("root"), ".maf", "coordination")
|
|
61
|
+
@taskrc = @coord_dir && File.join(@coord_dir, "taskrc")
|
|
62
|
+
@coord = @coord_dir && File.join(@coord_dir, "..", "bin", "coord")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def authorized?
|
|
66
|
+
@record && identity? && location? && board? && ProcessOwner.ancestor?(@record.fetch("pid")) && session?
|
|
67
|
+
rescue SystemCallError, KeyError, TypeError, ArgumentError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def record
|
|
74
|
+
return unless @env["MAF_SESSION_TOKEN"].to_s.match?(/\A[0-9a-f]{64}\z/)
|
|
75
|
+
|
|
76
|
+
data = JSON.parse(File.read(record_path))
|
|
77
|
+
data if valid_record?(data)
|
|
78
|
+
rescue SystemCallError, JSON::ParserError, TypeError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_path
|
|
83
|
+
File.join(@env.fetch("COORD_DIR", ""), "sessions", "#{@env['MAF_SESSION_TOKEN']}.maf.json")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def valid_record?(data)
|
|
87
|
+
data.is_a?(Hash) && %w[root dir role worker harness].all? { |key| data[key].is_a?(String) } &&
|
|
88
|
+
data["pid"].is_a?(Integer) && data["pid"].positive?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def identity?
|
|
92
|
+
@record.fetch("role") == @env["COORD_ROLE"] && @record.fetch("worker") == @env["COORD_WORKER"] &&
|
|
93
|
+
@record.fetch("pid").to_s == @env["COORD_SESSION_PID"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def location?
|
|
97
|
+
dir = File.realpath(@record.fetch("dir"))
|
|
98
|
+
current = File.realpath(@input.fetch("cwd", Dir.pwd))
|
|
99
|
+
current == dir || current.start_with?("#{dir}/")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def board?
|
|
103
|
+
File.realpath(@env.fetch("COORD_DIR")) == File.realpath(@coord_dir) &&
|
|
104
|
+
File.realpath(@env.fetch("TASKRC")) == File.realpath(@taskrc) && File.executable?(@coord)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def session?
|
|
108
|
+
id = @input["session_id"].to_s
|
|
109
|
+
return false if id.empty?
|
|
110
|
+
|
|
111
|
+
@input["hook_event_name"] == "SessionStart" ? bind(id) : bound?(id)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A new harness session (/clear, a new Codex or opencode session) replaces
|
|
115
|
+
# the bound ID. Process ownership already rejects sibling processes.
|
|
116
|
+
# A temp file per process and a rename keep parallel SessionStart hooks safe.
|
|
117
|
+
def bind(id)
|
|
118
|
+
temp = "#{record_path}.session.#{Process.pid}"
|
|
119
|
+
File.write(temp, id, perm: 0o600)
|
|
120
|
+
File.rename(temp, "#{record_path}.session")
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def bound?(id)
|
|
125
|
+
File.read("#{record_path}.session") == id
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# >>> multi-agent-flow >>>
|
|
2
|
+
# User Defined Attributes used by the `coord` wrapper.
|
|
3
|
+
# Do not remove: coord relies on these to attribute tasks to roles and workers.
|
|
4
|
+
uda.role.type=string
|
|
5
|
+
uda.worker.type=string
|
|
6
|
+
uda.scope.type=string
|
|
7
|
+
uda.taskref.type=string
|
|
8
|
+
uda.goalid.type=string
|
|
9
|
+
# Hide the "TASKRC override" footnote. coord always sets TASKRC.
|
|
10
|
+
verbose=blank,header,footnote,label,new-id,affected,edit,special,project,sync,filter,recur
|
|
11
|
+
# <<< multi-agent-flow <<<
|
data/assets/vault
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# vault - shared knowledge base watcher (graphify + Obsidian + MCP).
|
|
5
|
+
#
|
|
6
|
+
# Usage: vault [start|export|stop|status|age|mcp]
|
|
7
|
+
# vault # start the watcher (default); no-op if already running
|
|
8
|
+
# vault export # regenerate the Obsidian vault once, then exit
|
|
9
|
+
# vault stop
|
|
10
|
+
# vault status
|
|
11
|
+
# vault age [--json] # commits since the graph was built, and whether the graph is stale
|
|
12
|
+
# vault mcp # exec the stdio MCP server (for an MCP client config)
|
|
13
|
+
#
|
|
14
|
+
# graphify 0.9 removed the --obsidian/--obsidian-dir/--watch/--mcp flags. The
|
|
15
|
+
# current CLI is subcommand-based:
|
|
16
|
+
# graphify extract . --code-only initial code graph, no LLM
|
|
17
|
+
# graphify update . incremental code re-extract, no LLM
|
|
18
|
+
# graphify export obsidian --dir graphify-out/obsidian Obsidian notes + graph.canvas
|
|
19
|
+
# graphify-mcp stdio MCP server, spawned by the client
|
|
20
|
+
# The watcher polls `graphify update` and re-exports at VAULT_POLL seconds, so
|
|
21
|
+
# it does not need graphify's optional `watchdog` dependency. The graph and the
|
|
22
|
+
# vault live in graphify-out/ at the project root. graphify never reads that
|
|
23
|
+
# folder as input, except graphify-out/memory/. Bootstrap excludes it from git.
|
|
24
|
+
#
|
|
25
|
+
# Started automatically by bootstrap.rb when graphify is on PATH. Run by hand
|
|
26
|
+
# after installing graphify if it was not on PATH at bootstrap time.
|
|
27
|
+
|
|
28
|
+
require "fileutils"
|
|
29
|
+
require "json"
|
|
30
|
+
require "rbconfig"
|
|
31
|
+
|
|
32
|
+
abort "vault: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
|
|
33
|
+
|
|
34
|
+
module Vault
|
|
35
|
+
# The default folders of graphify. An agent that runs graphify export
|
|
36
|
+
# writes the same vault.
|
|
37
|
+
GRAPH_DIR = "graphify-out"
|
|
38
|
+
DIR = File.join(GRAPH_DIR, "obsidian")
|
|
39
|
+
GRAPH = File.join(GRAPH_DIR, "graph.json")
|
|
40
|
+
PID_FILE = ".maf/coordination/vault.pid"
|
|
41
|
+
LOG_FILE = ".maf/coordination/vault.log"
|
|
42
|
+
# A garbage or tiny VAULT_POLL would turn the watcher into a busy loop.
|
|
43
|
+
MIN_POLL = 5
|
|
44
|
+
POLL = [(ENV["VAULT_POLL"] || 30).to_i, MIN_POLL].max
|
|
45
|
+
EXTRACT_CMD = ["graphify", "extract", ".", "--code-only"].freeze
|
|
46
|
+
DOC_GRAPH_LOCK = File.join(".maf", "coordination", "doc-graph.lock")
|
|
47
|
+
UPDATE_CMD = ["graphify", "update", "."].freeze
|
|
48
|
+
EXPORT_CMD = ["graphify", "export", "obsidian", "--dir", DIR].freeze
|
|
49
|
+
|
|
50
|
+
# Any other argument (or none) starts the watcher.
|
|
51
|
+
COMMANDS = { "stop" => :stop, "status" => :status, "export" => :export,
|
|
52
|
+
"mcp" => :mcp, "daemon" => :loop_forever }.freeze
|
|
53
|
+
|
|
54
|
+
# Every path of this script is relative to the main checkout, where the graph
|
|
55
|
+
# and the watcher live. A command from a worktree moves there first.
|
|
56
|
+
# graphify writes its output where GRAPHIFY_OUT points. Every graphify call
|
|
57
|
+
# of this script, and of its daemon, inherits the variable.
|
|
58
|
+
def self.run(argv)
|
|
59
|
+
Dir.chdir(Daemon.main_root)
|
|
60
|
+
ENV["GRAPHIFY_OUT"] = File.expand_path(GRAPH_DIR)
|
|
61
|
+
return age(argv) if argv.first == "age"
|
|
62
|
+
|
|
63
|
+
Daemon.public_send(COMMANDS.fetch(argv.first, :start))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.age(argv)
|
|
67
|
+
GraphAge.print(json: argv.include?("--json"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# GraphAge counts the commits since the graph was built. The graph is stale
|
|
71
|
+
# when one of these commits changed a source or markdown file. The build
|
|
72
|
+
# time is the mtime of graph.json. It never fails: an unreadable state is
|
|
73
|
+
# reported as "unknown".
|
|
74
|
+
module GraphAge
|
|
75
|
+
SOURCE = /\.(md|rb|erb|py|js|jsx|ts|tsx|go|rs|java|kt|swift|c|h|cc|cpp|cs|php|sh|ex|exs|lua|scala)\z/
|
|
76
|
+
|
|
77
|
+
def self.state
|
|
78
|
+
return { state: "missing", commits: nil } unless File.exist?(GRAPH)
|
|
79
|
+
|
|
80
|
+
since = "--since=#{File.mtime(GRAPH).to_i}"
|
|
81
|
+
commits = git("rev-list", "--count", since, "HEAD")&.to_i
|
|
82
|
+
return { state: "unknown", commits: nil } unless commits
|
|
83
|
+
|
|
84
|
+
{ state: stale?(since) ? "stale" : "fresh", commits: commits }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.stale?(since)
|
|
88
|
+
git("log", since, "--name-only", "--format=").to_s.lines.any? { |line| line.strip.match?(SOURCE) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.git(*args)
|
|
92
|
+
output = IO.popen(["git", *args], err: File::NULL, &:read)
|
|
93
|
+
$?.success? ? output : nil
|
|
94
|
+
rescue SystemCallError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.text(data)
|
|
99
|
+
return "graph age: #{data[:state]}" unless data[:commits]
|
|
100
|
+
|
|
101
|
+
"graph age: #{data[:commits]} #{data[:commits] == 1 ? "commit" : "commits"} since build (#{data[:state]})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.print(json: false)
|
|
105
|
+
data = state
|
|
106
|
+
puts json ? JSON.generate(data) : text(data)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
module Daemon
|
|
111
|
+
def self.start
|
|
112
|
+
return puts("vault: already running (pid #{Pid.read})") if Pid.running?
|
|
113
|
+
abort "vault: graphify not found on PATH. Install: pip/uv install graphifyy" unless which("graphify")
|
|
114
|
+
|
|
115
|
+
FileUtils.mkdir_p(File.dirname(LOG_FILE))
|
|
116
|
+
spawn_detached
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.export
|
|
120
|
+
return puts("vault: #{GRAPH} missing. Run `vault` to build it first.") unless File.exist?(GRAPH)
|
|
121
|
+
|
|
122
|
+
system(*EXPORT_CMD) || warn("vault: obsidian export failed; see #{LOG_FILE}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# MCP is a stdio server the client spawns, not a background daemon. Point
|
|
126
|
+
# an MCP client at this command instead of a long-running process.
|
|
127
|
+
def self.mcp
|
|
128
|
+
abort "vault: graphify-mcp not found on PATH. Install: pip/uv install graphifyy" unless which("graphify-mcp")
|
|
129
|
+
abort "vault: #{GRAPH} missing. Run `vault` to build it first." unless File.exist?(GRAPH)
|
|
130
|
+
|
|
131
|
+
Kernel.exec("graphify-mcp", File.expand_path(GRAPH))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.main_root
|
|
135
|
+
common = IO.popen(%w[git rev-parse --git-common-dir], err: File::NULL, &:read).to_s.strip
|
|
136
|
+
common.empty? ? Dir.pwd : File.dirname(File.expand_path(common))
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
Dir.pwd
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.build_initial_graph
|
|
142
|
+
return if File.exist?(GRAPH)
|
|
143
|
+
|
|
144
|
+
puts "vault: no #{GRAPH} yet; building the code graph (no LLM)"
|
|
145
|
+
system(*EXTRACT_CMD) || warn("vault: graphify extract failed; retrying via update each poll")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.spawn_detached
|
|
149
|
+
pid = Process.spawn(RbConfig.ruby, File.expand_path(__FILE__), "daemon",
|
|
150
|
+
out: LOG_FILE, err: LOG_FILE, pgroup: true)
|
|
151
|
+
Process.detach(pid)
|
|
152
|
+
Pid.write(pid)
|
|
153
|
+
puts "vault: started (pid #{pid}), log at #{LOG_FILE}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The daemon builds the first graph itself, so `vault` (and bootstrap)
|
|
157
|
+
# return at once instead of blocking on a full extract. It exits when the
|
|
158
|
+
# pid file is gone (`vault stop`, or a removed checkout), so it never
|
|
159
|
+
# outlives its project.
|
|
160
|
+
def self.loop_forever
|
|
161
|
+
Pid.write(Process.pid)
|
|
162
|
+
build_initial_graph
|
|
163
|
+
exported_at = nil
|
|
164
|
+
exported_at = tick(exported_at) while File.exist?(PID_FILE)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# One poll: refresh the graph, re-export only when graph.json changed
|
|
168
|
+
# (Obsidian reloads every rewritten note), then sleep. Returns the graph
|
|
169
|
+
# mtime that was last exported.
|
|
170
|
+
def self.tick(exported_at)
|
|
171
|
+
system(*UPDATE_CMD) unless File.exist?(DOC_GRAPH_LOCK)
|
|
172
|
+
mtime = File.exist?(GRAPH) ? File.mtime(GRAPH) : nil
|
|
173
|
+
export if mtime && mtime != exported_at
|
|
174
|
+
sleep POLL
|
|
175
|
+
mtime || exported_at
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.stop
|
|
179
|
+
return puts("vault: not running") unless Pid.running?
|
|
180
|
+
|
|
181
|
+
pid = Pid.read
|
|
182
|
+
Process.kill("TERM", -pid)
|
|
183
|
+
Pid.clear
|
|
184
|
+
puts "vault: stopped"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.status
|
|
188
|
+
puts Pid.running? ? "vault: running (pid #{Pid.read})" : "vault: not running"
|
|
189
|
+
puts File.exist?(GRAPH) ? "graph: #{GRAPH}" : "graph: #{GRAPH} missing"
|
|
190
|
+
puts File.directory?(DIR) ? "obsidian: #{DIR}/" : "obsidian: #{DIR}/ missing"
|
|
191
|
+
puts GraphAge.text(GraphAge.state)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.which(bin)
|
|
195
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, bin)) }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
module Pid
|
|
200
|
+
# A corrupt or empty pid file reads as 0; Process.kill(0, 0) would signal
|
|
201
|
+
# this process's own group, so treat a non-positive pid as not running.
|
|
202
|
+
def self.running?
|
|
203
|
+
pid = File.exist?(PID_FILE) ? read : 0
|
|
204
|
+
pid.positive? && Process.kill(0, pid) && true
|
|
205
|
+
rescue Errno::ESRCH, Errno::ENOENT, Errno::EINVAL, RangeError
|
|
206
|
+
false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.read
|
|
210
|
+
File.read(PID_FILE).to_i
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.write(pid)
|
|
214
|
+
FileUtils.mkdir_p(File.dirname(PID_FILE))
|
|
215
|
+
File.write(PID_FILE, pid)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.clear
|
|
219
|
+
File.delete(PID_FILE)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
Vault.run(ARGV) if __FILE__ == $PROGRAM_NAME
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# worktree-env.rb - example project hook for `coord worktree` (Rails).
|
|
4
|
+
#
|
|
5
|
+
# Copy this file to .maf/coordination/worktree-env.rb. Git ignores that folder.
|
|
6
|
+
# To commit the hook, run `git add -f .maf/coordination/worktree-env.rb`.
|
|
7
|
+
# `coord worktree` runs the hook for each worktree and appends the output
|
|
8
|
+
# to the worktree's .maf/env.sh.
|
|
9
|
+
#
|
|
10
|
+
# Input: COORD_SLOT (1, 2, ...; the main worktree is slot 0) and
|
|
11
|
+
# COORD_WORKTREE (absolute path of the worktree).
|
|
12
|
+
# Output: one `export NAME=VALUE` line per variable. `coord worktree` keeps
|
|
13
|
+
# only those lines, so a later `source .maf/env.sh` runs no other
|
|
14
|
+
# line the hook prints.
|
|
15
|
+
#
|
|
16
|
+
# Use the variables in the project, for example in config/database.yml:
|
|
17
|
+
# test:
|
|
18
|
+
# database: app_test<%= ENV["TEST_ENV_NUMBER"] %>
|
|
19
|
+
# Then create each worktree's test database one time:
|
|
20
|
+
# bin/rails db:test:prepare
|
|
21
|
+
|
|
22
|
+
slot = ENV.fetch("COORD_SLOT").to_i
|
|
23
|
+
|
|
24
|
+
puts "export TEST_ENV_NUMBER=#{slot}"
|
|
25
|
+
puts "export PORT=#{3000 + slot}"
|
|
26
|
+
puts "export CAPYBARA_SERVER_PORT=#{4000 + slot}"
|
data/exe/maf
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# maf - multi-agent-flow command line tool.
|
|
5
|
+
#
|
|
6
|
+
# Run maf in the project root. maf uses the current directory as the project.
|
|
7
|
+
# Install it with: gem install maf. In a clone, link this file into a
|
|
8
|
+
# directory on PATH. __dir__ follows the link to find lib/.
|
|
9
|
+
lib = File.expand_path("../lib/maf.rb", __dir__)
|
|
10
|
+
abort "maf: cannot find #{lib}. Run: gem install maf. Or link exe/maf into PATH with ln -s. Do not copy it." \
|
|
11
|
+
unless File.exist?(lib)
|
|
12
|
+
require lib
|
|
13
|
+
|
|
14
|
+
Maf::CLI.new(ARGV).run
|