gitbroker 0.3.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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/gitbroker +10 -0
  4. data/lib/gitbroker/agents/base.rb +48 -0
  5. data/lib/gitbroker/agents/claude.rb +92 -0
  6. data/lib/gitbroker/agents/codex.rb +72 -0
  7. data/lib/gitbroker/agents.rb +18 -0
  8. data/lib/gitbroker/api.rb +53 -0
  9. data/lib/gitbroker/cable_client.rb +213 -0
  10. data/lib/gitbroker/cli.rb +199 -0
  11. data/lib/gitbroker/config.rb +88 -0
  12. data/lib/gitbroker/daemon.rb +166 -0
  13. data/lib/gitbroker/explain_verifier.rb +31 -0
  14. data/lib/gitbroker/gh_check.rb +22 -0
  15. data/lib/gitbroker/git_context.rb +105 -0
  16. data/lib/gitbroker/hook.rb +50 -0
  17. data/lib/gitbroker/hooks_installer.rb +114 -0
  18. data/lib/gitbroker/launcher.rb +62 -0
  19. data/lib/gitbroker/lifecycle.rb +114 -0
  20. data/lib/gitbroker/local_relay.rb +100 -0
  21. data/lib/gitbroker/login.rb +61 -0
  22. data/lib/gitbroker/neutralizer.rb +80 -0
  23. data/lib/gitbroker/process_scanner.rb +103 -0
  24. data/lib/gitbroker/repo_scanner.rb +50 -0
  25. data/lib/gitbroker/run_script.rb +58 -0
  26. data/lib/gitbroker/runner.rb +41 -0
  27. data/lib/gitbroker/service_installer.rb +145 -0
  28. data/lib/gitbroker/skill_installer.rb +30 -0
  29. data/lib/gitbroker/task_files.rb +90 -0
  30. data/lib/gitbroker/task_runner.rb +258 -0
  31. data/lib/gitbroker/url_handler_installer.rb +87 -0
  32. data/lib/gitbroker/version.rb +5 -0
  33. data/lib/gitbroker/worktree.rb +93 -0
  34. data/lib/gitbroker.rb +39 -0
  35. data/plugin/.claude-plugin/plugin.json +5 -0
  36. data/plugin/skills/gitbroker/SKILL.md +36 -0
  37. data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
  38. metadata +95 -0
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require "rbconfig"
6
+ require "socket"
7
+
8
+ module Gitbroker
9
+ class CLI
10
+ HELP = <<~TEXT
11
+ Usage: gitbroker <command> [options]
12
+
13
+ Commands:
14
+ login [--server URL] [--name NAME] Pair this machine with GitBroker (you type the code at /devices)
15
+ start Stay connected; run your agent when you ask from a PR card
16
+ launch Start the companion in the background if it is not running
17
+ stop Stop the companion (and its background service, so it stays stopped)
18
+ restart Restart the companion, e.g. after `gem update gitbroker`
19
+ install-service Run `gitbroker start` in the background (launchd / systemd --user), and let
20
+ the website's "Start companion" button wake it (gitbroker:// handler)
21
+ install-skill Install the gitbroker skills into ~/.claude/skills
22
+ status Show pairing, configuration and the repositories found
23
+ clean Remove finished agent worktrees (keeps ones with uncommitted work)
24
+ install-hooks [--yes] Show your own Claude Code sessions on your cards (asks first)
25
+ uninstall-hooks Remove those hooks
26
+ help Show this help
27
+
28
+ Config: ~/.config/gitbroker/config.yml (server, roots, scan_depth, terminal, worktree_root, agent,
29
+ claude_args, codex_args). Your agent runs as you, with your own login and credentials.
30
+ Agent activity is visible only to you; only status is sent, never prompts, file contents or tool input.
31
+ TEXT
32
+
33
+ def initialize(argv, out: $stdout, err: $stderr, env: ENV, home: Dir.home, input: $stdin)
34
+ @argv = argv.dup
35
+ @out = out
36
+ @err = err
37
+ @env = env
38
+ @home = home
39
+ @input = input
40
+ end
41
+
42
+ def run
43
+ command = @argv.shift || "help"
44
+ return task_ended if command == "task-ended"
45
+ return hook if command == "hook"
46
+
47
+ case command
48
+ when "login" then login
49
+ when "start" then start
50
+ when "launch" then launch
51
+ when "stop" then stop
52
+ when "restart" then restart
53
+ when "install-service" then install_service
54
+ when "install-skill" then install_skill
55
+ when "status" then status
56
+ when "clean" then clean
57
+ when "install-hooks" then install_hooks
58
+ when "uninstall-hooks" then uninstall_hooks
59
+ when "help", "-h", "--help" then @out.puts(HELP)
60
+ else
61
+ @err.puts "Unknown command: #{command}"
62
+ @err.puts HELP
63
+ return 1
64
+ end
65
+ 0
66
+ rescue Error, OptionParser::ParseError => e
67
+ @err.puts "gitbroker: #{e.message}"
68
+ 1
69
+ end
70
+
71
+ private
72
+
73
+ def config = @config ||= Config.load(dir: Config.default_dir(@env), env: @env)
74
+
75
+ def login
76
+ options = {}
77
+ OptionParser.new do |parser|
78
+ parser.on("--server URL") { options[:server] = _1 }
79
+ parser.on("--name NAME") { options[:name] = _1 }
80
+ end.parse!(@argv)
81
+ @config = config.save!("server" => options[:server]) if options[:server]
82
+ Login.new(config:, out: @out, name: options[:name] || Socket.gethostname).run
83
+ end
84
+
85
+ def start
86
+ raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
87
+
88
+ @out.puts "GitBroker companion #{VERSION} → #{config.server} (agent: #{config.agent}, terminal: #{config.terminal})"
89
+ Daemon.build(config, out: @out).run
90
+ end
91
+
92
+ def install_service
93
+ raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
94
+
95
+ path = ServiceInstaller.new(home: @home, env: @env).install!
96
+ @out.puts "Installed and started #{path}. Run this again after upgrading the gem."
97
+ handler = UrlHandlerInstaller.new(home: @home).install!
98
+ @out.puts "Registered #{handler} so the website's \"Start companion\" button can wake it."
99
+ end
100
+
101
+ # The target of the website's gitbroker://start link and of `gitbroker launch` by hand: start the background
102
+ # service, or the daemon itself when none is installed. Safe to run when it is already running.
103
+ def launch
104
+ raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
105
+ lifecycle.launch
106
+ end
107
+
108
+ def lifecycle = @lifecycle ||= Lifecycle.new(dir: config.dir, home: @home, service: ServiceInstaller.new(home: @home, env: @env))
109
+
110
+ # Works on this machine only. With a background service installed it is stopped through launchd / systemd, so it
111
+ # stays stopped until `gitbroker launch` (or the website's "Start companion" button).
112
+ def stop
113
+ @out.puts(lifecycle.stop ? "Stopped the companion." : "The companion is not running.")
114
+ end
115
+
116
+ def restart
117
+ raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
118
+
119
+ lifecycle.restart
120
+ @out.puts "Restarting the companion."
121
+ end
122
+
123
+ def install_skill
124
+ SkillInstaller.new(home: @home, env: @env).install!.each { @out.puts "Installed the #{File.basename(_1)} skill to #{_1}." }
125
+ end
126
+
127
+ def status
128
+ @out.puts "GitBroker companion #{VERSION}"
129
+ @out.puts "Server: #{config.server}"
130
+ @out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `gitbroker login`)"}"
131
+ @out.puts "Agent: #{config.agent}"
132
+ @out.puts "Terminal: #{config.terminal}"
133
+ @out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (gitbroker install-hooks)"}"
134
+ @out.puts "Roots: #{config.roots.join(", ")}"
135
+ @out.puts "Worktrees: #{config.worktree_root}"
136
+ @out.puts "Task worktrees: #{Worktree.new(root: config.worktree_root).list.size} (running or kept; `gitbroker clean` removes finished ones)"
137
+ repositories = RepoScanner.new(roots: config.roots, depth: config.scan_depth, exclude: [ config.worktree_root ]).scan
138
+ @out.puts "Repositories found: #{repositories.size}"
139
+ repositories.each { @out.puts " #{_1["full_name"]} #{_1["path"]}" }
140
+ end
141
+
142
+ # `git worktree remove` without --force refuses a worktree with modified or untracked files, so work is never lost.
143
+ def clean
144
+ worktree = Worktree.new(root: config.worktree_root)
145
+ worktree.list.each do |dir|
146
+ git_dir = GitContext.git_dir(dir)
147
+ repo_path = git_dir && File.dirname(GitContext.common_dir(git_dir))
148
+ removed = repo_path && File.directory?(repo_path) && worktree.remove!(dir, repo_path:)
149
+ @out.puts(removed ? "Removed #{dir}" : "Kept #{dir} (uncommitted work)")
150
+ end
151
+ end
152
+
153
+ def hooks_installer = HooksInstaller.default(env: @env, home: @home)
154
+
155
+ # Called by Claude Code on every hook event: silent, always exit 0.
156
+ def hook
157
+ Hook.new(event: @argv.shift, input: @input, socket_path: File.join(Config.default_dir(@env), "daemon.sock"), env: @env).run
158
+ 0
159
+ rescue StandardError
160
+ 0
161
+ end
162
+
163
+ def install_hooks
164
+ yes = @argv.delete("--yes")
165
+ installer = hooks_installer
166
+ @out.puts "This adds Claude Code command hooks (#{HooksInstaller::EVENTS.join(", ")}) to #{installer.settings_path}."
167
+ @out.puts "Each runs `gitbroker hook <event>`, which tells your running companion the session id, repository, branch " \
168
+ "and head SHA. It never sends prompts, file contents or tool input, and only you see the result."
169
+ unless yes
170
+ @out.print "Add the hooks? [y/N] "
171
+ return @out.puts("Nothing changed.") unless %w[y yes].include?(@input.gets.to_s.strip.downcase)
172
+ end
173
+ backup = installer.install!
174
+ @out.puts "Hooks installed#{" (previous settings saved to #{backup})" if backup}. New Claude Code sessions pick them up."
175
+ end
176
+
177
+ def uninstall_hooks
178
+ if hooks_installer.installed? || File.exist?(hooks_installer.settings_path)
179
+ backup = hooks_installer.uninstall!
180
+ @out.puts(backup ? "Hooks removed (previous settings saved to #{backup})." : "No GitBroker hooks found; nothing changed.")
181
+ else
182
+ @out.puts "No Claude Code settings file; nothing to remove."
183
+ end
184
+ end
185
+
186
+ # Called by the terminal run script after the agent exits: silent, always exit 0.
187
+ def task_ended
188
+ id = @env["GITBROKER_TASK_ID"].to_s
189
+ token = @env["GITBROKER_TASK_TOKEN"].to_s
190
+ return 0 if id.empty? || token.empty?
191
+
192
+ Api.new(server: @env["GITBROKER_SERVER"] || config.server, token:)
193
+ .post("/agent_tasks/#{id}/events", { hook_event_name: "TaskEnded", exit_status: @argv.first.to_s })
194
+ 0
195
+ rescue StandardError # never break the user's terminal session over a status report
196
+ 0
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Gitbroker
7
+ # ~/.config/gitbroker/config.yml (settings) and credentials.yml (0600, device token). Spec §4.1: roots, terminal,
8
+ # worktree location, preferred agent, and the agents' command-line args for autonomous runs (spec §4.2 step 5).
9
+ class Config
10
+ TERMINALS = %w[terminal iterm2 ghostty warp tmux].freeze
11
+ AGENTS = %w[claude codex].freeze
12
+ DEFAULT_SERVER = "https://git.broker"
13
+ DEFAULT_CLAUDE_ARGS = %w[--permission-mode auto --permission-prompts none].freeze
14
+ DEFAULT_CODEX_ARGS = %w[--sandbox workspace-write -c sandbox_workspace_write.network_access=true].freeze
15
+ # GitBroker sets these itself for every run; user args may not override them.
16
+ RESERVED_ARGS = %w[-p --print --output-format --mcp-config --strict-mcp-config --plugin-dir --json].freeze
17
+
18
+ attr_reader :dir, :data
19
+
20
+ def self.default_dir(env = ENV)
21
+ env["GITBROKER_CONFIG_DIR"] || File.join(env["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config"), "gitbroker")
22
+ end
23
+
24
+ def self.load(dir: default_dir, env: ENV, platform: Gitbroker.platform)
25
+ path = File.join(dir, "config.yml")
26
+ data = File.exist?(path) ? YAML.safe_load_file(path) || {} : {}
27
+ new(dir:, data:, env:, platform:)
28
+ end
29
+
30
+ def initialize(dir:, data: {}, env: ENV, platform: Gitbroker.platform)
31
+ @dir = dir
32
+ @data = data.to_h.transform_keys(&:to_s)
33
+ @env = env
34
+ @platform = platform
35
+ validate!
36
+ end
37
+
38
+ def server = (@env["GITBROKER_SERVER"] || data["server"] || DEFAULT_SERVER).to_s.chomp("/")
39
+ def cable_url = "#{server.sub(/\Ahttp/, "ws")}/cable"
40
+ def roots = Array(data["roots"] || [ "~/Code" ]).map { File.expand_path(_1) }
41
+ def scan_depth = Integer(data["scan_depth"] || 3)
42
+ def terminal = (data["terminal"] || (@platform == "macos" ? "terminal" : "tmux")).to_s
43
+ def worktree_root = File.expand_path(data["worktree_root"] || "~/.local/share/gitbroker/worktrees")
44
+ def state_dir = File.expand_path(data["state_dir"] || "~/.local/state/gitbroker")
45
+ def agent = (data["agent"] || "claude").to_s
46
+ def claude_command = (data["claude_command"] || "claude").to_s
47
+ def codex_command = (data["codex_command"] || "codex").to_s
48
+ def claude_args = data.key?("claude_args") ? data["claude_args"] : DEFAULT_CLAUDE_ARGS
49
+ def codex_args = data.key?("codex_args") ? data["codex_args"] : DEFAULT_CODEX_ARGS
50
+
51
+ def credentials_path = File.join(dir, "credentials.yml")
52
+ def device_token = credentials["device_token"]
53
+ def device_id = credentials["device_id"]
54
+ def paired? = !device_token.to_s.empty?
55
+
56
+ def save!(updates)
57
+ merged = data.merge(updates.transform_keys(&:to_s)).compact
58
+ FileUtils.mkdir_p(dir, mode: 0o700)
59
+ File.write(File.join(dir, "config.yml"), merged.to_yaml)
60
+ self.class.new(dir:, data: merged, env: @env, platform: @platform)
61
+ end
62
+
63
+ def save_credentials!(device_token:, device_id:)
64
+ FileUtils.mkdir_p(dir, mode: 0o700)
65
+ File.write(credentials_path, { "device_token" => device_token, "device_id" => device_id, "server" => server }.to_yaml, perm: 0o600)
66
+ File.chmod(0o600, credentials_path)
67
+ @credentials = nil
68
+ end
69
+
70
+ private
71
+
72
+ def credentials
73
+ @credentials ||= File.exist?(credentials_path) ? YAML.safe_load_file(credentials_path) || {} : {}
74
+ end
75
+
76
+ def validate!
77
+ raise Error, "Unknown terminal #{terminal.inspect}. Use one of: #{TERMINALS.join(", ")}" unless TERMINALS.include?(terminal)
78
+ raise Error, "Unknown agent #{agent.inspect}. Use one of: #{AGENTS.join(", ")}" unless AGENTS.include?(agent)
79
+
80
+ { "claude_args" => claude_args, "codex_args" => codex_args }.each do |key, args|
81
+ raise Error, "#{key} must be a list of strings" unless args.is_a?(Array) && args.all?(String)
82
+
83
+ reserved = args.map { _1.split("=").first } & RESERVED_ARGS
84
+ raise Error, "#{key} may not set #{reserved.join(", ")}; GitBroker sets those itself" if reserved.any?
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+ require "set"
6
+
7
+ module Gitbroker
8
+ # `gitbroker start`: keep the device connected, report checkouts, claim and run tasks, and relay the status of
9
+ # agents the user started themselves (relay: Task 31, process scanner: Task 32).
10
+ class Daemon
11
+ RESCAN_EVERY = 300
12
+ MAX_BACKOFF = 60
13
+ BATCH = 50
14
+ # Spec §5.2 / main §18.4: nothing else about an observed agent ever leaves the machine.
15
+ OBSERVATION_FIELDS = %w[event agent external_session_id repository branch head_sha occurred_at].freeze
16
+
17
+ def self.build(config, out: $stdout, relay: LocalRelay.new(path: File.join(config.dir, "daemon.sock")),
18
+ process_scanner: build_process_scanner(config))
19
+ raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
20
+
21
+ new(scanner: RepoScanner.new(roots: config.roots, depth: config.scan_depth, exclude: [ config.worktree_root ]),
22
+ client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
23
+ task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out:, relay:, process_scanner:,
24
+ lifecycle: Lifecycle.new(dir: config.dir))
25
+ end
26
+
27
+ # Claude sessions with hooks installed report real working/waiting status, so the scanner only looks for `claude`
28
+ # without hooks (checked on every scan, so installing hooks takes effect without a restart). GitBroker's own
29
+ # worktrees are skipped: those agents are tasks, reported by the task.
30
+ def self.build_process_scanner(config)
31
+ hooks = HooksInstaller.default
32
+ skip_claude = lambda do
33
+ hooks.installed?
34
+ rescue Error
35
+ false
36
+ end
37
+ ProcessScanner.new(skip_claude:, exclude: [ config.worktree_root ])
38
+ end
39
+
40
+ def initialize(scanner:, client_factory:, task_runner_factory:, out: $stdout, sleeper: ->(seconds) { sleep(seconds) },
41
+ clock: -> { Time.now }, spawner: ->(&block) { Thread.new(&block) }, relay: nil, process_scanner: nil, lifecycle: nil)
42
+ @scanner = scanner
43
+ @client_factory = client_factory
44
+ @task_runner = task_runner_factory.call(method(:report))
45
+ @out = out
46
+ @sleeper = sleeper
47
+ @clock = clock
48
+ @spawner = spawner
49
+ @relay = relay
50
+ @process_scanner = process_scanner
51
+ @lifecycle = lifecycle
52
+ @repositories = []
53
+ @running = Set.new
54
+ @running_lock = Mutex.new
55
+ end
56
+
57
+ def run
58
+ @backoff = 1
59
+ @relay&.start
60
+ @lifecycle&.write_pid
61
+ until @stopping
62
+ begin
63
+ @client = @client_factory.call
64
+ wire(@client)
65
+ @client.run
66
+ rescue CableClient::Unauthorized
67
+ raise Error, "GitBroker rejected this device (revoked?). Run `gitbroker login` to pair again."
68
+ rescue Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
69
+ @out.puts "Connection problem: #{e.message}"
70
+ end
71
+ break if @stopping
72
+
73
+ @sleeper.call(@backoff)
74
+ @backoff = [ @backoff * 2, MAX_BACKOFF ].min
75
+ end
76
+ ensure
77
+ @relay&.stop
78
+ @lifecycle&.clear_pid
79
+ # Only now, with the connection closed, so two daemons never share the device.
80
+ @lifecycle.spawn_daemon if @respawn && @lifecycle
81
+ end
82
+
83
+ def stop
84
+ @stopping = true
85
+ @client&.stop
86
+ end
87
+
88
+ # Task runners report progress and results through whatever connection is live. A report sent while the device
89
+ # is reconnecting is lost; the server's sweep settles the task.
90
+ def report(action, data) = @client&.perform(action, data)
91
+
92
+ private
93
+
94
+ def wire(client)
95
+ client.on_subscribed do
96
+ @backoff = 1
97
+ rescan(client)
98
+ @out.puts "Connected. #{@repositories.size} repositories reported."
99
+ end
100
+ client.on_tick do
101
+ client.perform("heartbeat", "version" => VERSION)
102
+ rescan(client) if @scanned_at.nil? || @clock.call - @scanned_at >= RESCAN_EVERY
103
+ observe(client, @process_scanner.scan) if @process_scanner
104
+ end
105
+ client.on_poll { observe(client, @relay.drain) if @relay }
106
+ client.on_message { |message| handle(client, message) }
107
+ end
108
+
109
+ # The server replaces its list with each report, so every report carries the full list.
110
+ def rescan(client)
111
+ @repositories = @scanner.scan
112
+ @scanned_at = @clock.call
113
+ client.perform("repositories", "repositories" => @repositories)
114
+ end
115
+
116
+ def observe(client, events)
117
+ clean = Array(events).filter_map { _1.is_a?(Hash) ? _1.slice(*OBSERVATION_FIELDS) : nil }
118
+ clean.each_slice(BATCH) { client.perform("observe", "events" => _1) }
119
+ end
120
+
121
+ # The website's Restart / Stop buttons (Device#control!). Nothing else about the message is trusted or used.
122
+ def control(action)
123
+ return unless @lifecycle
124
+
125
+ case action
126
+ when "stop"
127
+ @out.puts "Stopping (asked from GitBroker)."
128
+ @lifecycle.stop_service
129
+ stop
130
+ when "restart"
131
+ @out.puts "Restarting (asked from GitBroker)."
132
+ # With a service the manager restarts us (it may kill us mid-call); otherwise respawn once the run loop has ended.
133
+ return if @lifecycle.restart_service
134
+
135
+ @respawn = true
136
+ stop
137
+ end
138
+ end
139
+
140
+ def handle(client, message)
141
+ return control(message["action"]) if message["type"] == "control"
142
+
143
+ id = message["task_id"]
144
+ return unless id.is_a?(String)
145
+
146
+ case message["type"]
147
+ when "task_available" then client.perform("claim", "task_id" => id)
148
+ when "cancel" then @task_runner.cancel(id)
149
+ when "task" then start_task(id, message)
150
+ end
151
+ end
152
+
153
+ def start_task(id, message)
154
+ return unless @running_lock.synchronize { @running.add?(id) }
155
+
156
+ repositories = @repositories
157
+ @spawner.call do
158
+ @task_runner.run(message, repositories:)
159
+ rescue StandardError => e
160
+ @out.puts "Task #{id} crashed: #{e.class}: #{e.message}"
161
+ ensure
162
+ @running_lock.synchronize { @running.delete(id) }
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitbroker
4
+ # Understanding spec §6.2: after an explain run finishes writing, a second agent run in a fresh context checks every
5
+ # walkthrough step, summary, intuition claim and quiz answer against the code, and sends verify_explanation.
6
+ module ExplainVerifier
7
+ BRIEF = /^Run brief: .+$/
8
+
9
+ def self.prompt(task)
10
+ brief = task.fetch("prompt")[BRIEF] or raise Error, "The explain prompt has no run brief."
11
+ <<~PROMPT
12
+ Use the gitbroker-explain skill in verify mode. Someone else wrote this explanation; check it against the code with fresh eyes.
13
+ #{brief}
14
+ Read it with show_explanation (pass pull_request and explanation_id from the run brief), read the code each block refers to in
15
+ this worktree, and send every verdict in one verify_explanation call. Change nothing else.
16
+ PROMPT
17
+ end
18
+
19
+ # One `finished` for the task: the costs add up. A verifier that failed makes the task failed (the server then keeps the
20
+ # explanation "not verified"); one that was cancelled is cancelled.
21
+ def self.combine(write, verify)
22
+ costs = [ write.cost_usd, verify.cost_usd ].compact
23
+ status, code = case verify.status
24
+ when "done" then [ "done", nil ]
25
+ when "cancelled" then [ "cancelled", "cancelled" ]
26
+ else [ "failed", verify.error_code || "agent_failed" ]
27
+ end
28
+ TaskRunner::Outcome.new(status, costs.empty? ? nil : costs.sum.round(6), code, verify.last_output || write.last_output)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitbroker
4
+ # Spec §3.4: agents write to GitHub with the user's own gh only when gh's active github.com account is the
5
+ # GitBroker connection's login; otherwise through GitBroker's MCP write tools. Both write as the user.
6
+ class GhCheck
7
+ LOGIN = /Logged in to github\.com (?:account |as )([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/
8
+
9
+ def initialize(runner: Runner.new)
10
+ @runner = runner
11
+ end
12
+
13
+ def matches?(login)
14
+ return false if login.to_s.empty?
15
+
16
+ output = @runner.capture("gh", "auth", "status", "--active", "--hostname", "github.com")
17
+ output = @runner.capture("gh", "auth", "status", "--hostname", "github.com") if output.include?("unknown flag")
18
+ logins = output.scan(LOGIN).flatten.uniq
19
+ logins.one? && logins.first.casecmp?(login.to_s)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitbroker
4
+ # (owner/repo, branch, head SHA) for a working directory, read from .git files. The companion never runs git in
5
+ # directories it merely scans or that a hook/process reports, so a repository's config can never execute anything.
6
+ module GitContext
7
+ SHA = /\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/
8
+ MAX_DEPTH = 64
9
+
10
+ module_function
11
+
12
+ def resolve(cwd)
13
+ return nil if cwd.to_s.empty? || !File.directory?(cwd)
14
+
15
+ root = enclosing_checkout(File.expand_path(cwd)) or return nil
16
+ git_dir = git_dir(root) or return nil
17
+ repository = repository_from(remote_urls_in(common_dir(git_dir))) or return nil
18
+ branch, head_sha = head(git_dir)
19
+ { "repository" => repository, "branch" => branch, "head_sha" => head_sha }
20
+ rescue SystemCallError, IOError
21
+ nil
22
+ end
23
+
24
+ def git_dir(dir)
25
+ dot_git = File.join(dir, ".git")
26
+ return dot_git if File.directory?(dot_git)
27
+ return nil unless File.file?(dot_git)
28
+
29
+ target = File.read(dot_git, 4_096)[/\Agitdir: (.+)$/, 1]
30
+ target && File.expand_path(target.strip, dir)
31
+ end
32
+
33
+ def common_dir(git_dir)
34
+ file = File.join(git_dir, "commondir")
35
+ File.file?(file) ? File.expand_path(File.read(file, 4_096).strip, git_dir) : git_dir
36
+ end
37
+
38
+ def remote_urls(dir)
39
+ git_dir = git_dir(dir)
40
+ git_dir ? remote_urls_in(common_dir(git_dir)) : []
41
+ rescue SystemCallError, IOError
42
+ []
43
+ end
44
+
45
+ # Minimal git-config reader: [remote "name"] sections, url = … keys.
46
+ def remote_urls_in(common)
47
+ path = File.join(common, "config")
48
+ return [] unless File.file?(path)
49
+
50
+ remote = nil
51
+ File.foreach(path).filter_map do |line|
52
+ line = line.strip
53
+ if (section = line.match(/\A\[\s*remote\s+"([^"]+)"\s*\]\z/))
54
+ remote = section[1]
55
+ nil
56
+ elsif line.start_with?("[")
57
+ remote = nil
58
+ elsif remote && (url = line.match(/\Aurl\s*=\s*(.+)\z/))
59
+ [ remote, url[1].strip.delete_prefix('"').delete_suffix('"') ]
60
+ end
61
+ end
62
+ end
63
+
64
+ def repository_from(remotes)
65
+ remotes.sort_by { |name, _url| name == "origin" ? 0 : 1 }.lazy.filter_map { |_name, url| RepoScanner.full_name_from(url) }.first
66
+ end
67
+
68
+ def head(git_dir)
69
+ head = File.read(File.join(git_dir, "HEAD"), 1_024).strip
70
+ return [ nil, (head if head.match?(SHA)) ] unless head.start_with?("ref: ")
71
+
72
+ ref = head.delete_prefix("ref: ")
73
+ [ ref.start_with?("refs/heads/") ? ref.delete_prefix("refs/heads/") : nil, read_ref(git_dir, ref) ]
74
+ end
75
+
76
+ def read_ref(git_dir, ref)
77
+ return nil if ref.include?("..")
78
+
79
+ [ git_dir, common_dir(git_dir) ].uniq.each do |dir|
80
+ loose = File.join(dir, ref)
81
+ return File.read(loose, 128).strip if File.file?(loose)
82
+ end
83
+ packed = File.join(common_dir(git_dir), "packed-refs")
84
+ return nil unless File.file?(packed)
85
+
86
+ File.foreach(packed) do |line|
87
+ sha, name = line.strip.split(" ", 2)
88
+ return sha if name == ref && sha.match?(SHA)
89
+ end
90
+ nil
91
+ end
92
+
93
+ def enclosing_checkout(dir)
94
+ MAX_DEPTH.times do
95
+ return dir if File.exist?(File.join(dir, ".git"))
96
+
97
+ parent = File.dirname(dir)
98
+ return nil if parent == dir
99
+
100
+ dir = parent
101
+ end
102
+ nil
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Gitbroker
7
+ # `gitbroker hook <Event>`, run by Claude Code's user-level command hooks. It must never slow down or break Claude
8
+ # Code: it prints nothing, never raises, runs no subprocess, and skips everything when the daemon is not running or
9
+ # the session is a GitBroker task (reported through the task itself). It forwards status only; the prompt is never
10
+ # read.
11
+ class Hook
12
+ EVENTS = { "SessionStart" => "start", "UserPromptSubmit" => "prompt", "Stop" => "stop", "SessionEnd" => "end" }.freeze
13
+ TIMEOUT = 0.2
14
+ MAX_INPUT = 1_000_000
15
+
16
+ def initialize(event:, input:, socket_path:, env: ENV, clock: -> { Time.now })
17
+ @event = event.to_s
18
+ @input = input
19
+ @socket_path = socket_path
20
+ @env = env
21
+ @clock = clock
22
+ end
23
+
24
+ def run
25
+ observation = build
26
+ LocalRelay.send_line(@socket_path, JSON.generate(observation), timeout: TIMEOUT) if observation
27
+ nil
28
+ rescue StandardError
29
+ nil
30
+ end
31
+
32
+ private
33
+
34
+ def build
35
+ kind = EVENTS[@event]
36
+ return nil unless kind && File.socket?(@socket_path)
37
+ return nil unless @env["GITBROKER_TASK_ID"].to_s.empty?
38
+
39
+ data = JSON.parse(@input.read(MAX_INPUT).to_s)
40
+ return nil unless data.is_a?(Hash)
41
+
42
+ session_id = data["session_id"].to_s
43
+ return nil if session_id.empty?
44
+
45
+ context = GitContext.resolve(data["cwd"].to_s) || {} # files only; never runs git in the hook's cwd
46
+ { "event" => kind, "agent" => "claude_code", "external_session_id" => session_id,
47
+ "occurred_at" => @clock.call.utc.iso8601 }.merge(context)
48
+ end
49
+ end
50
+ end