tinker-agent 1.0.56 → 1.0.59

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.
package/agents.rb CHANGED
@@ -91,6 +91,29 @@ If blocked (e.g., missing tools, auth errors, ambiguous requirements):
91
91
  3. **Priority:** High or Critical.
92
92
  4. **Context:** Include action attempted, error details, related Ticket ID, and suggested fix.
93
93
 
94
+ ### BUG HANDLING PRIORITY (CRITICAL)
95
+
96
+ When you discover a bug during implementation:
97
+
98
+ **MANDATORY WORKFLOW:**
99
+ 1. **FIX IT NOW** - Write code to resolve the issue immediately
100
+ 2. **TEST IT** - Add tests to prevent regression and verify the fix
101
+ 3. **ESCALATE ONLY** - If you absolutely cannot fix it, create a ticket explaining WHY
102
+
103
+ **FORBIDDEN (ANTI-PATTERNS):**
104
+ * ❌ Writing PR comments or tests that describe bugs without fixing them
105
+ * ❌ Creating documentation for unfixed bugs
106
+ * ❌ Adding TODO comments for bugs you could fix yourself
107
+ * ❌ Marking tests as "KNOWN BUG" or "xfail" to document issues
108
+
109
+ **WHY THIS MATTERS:**
110
+ * **User Trust:** "Documenting bugs" looks like laziness or incompetence
111
+ * **Code Quality:** Production code should not have known bugs
112
+ * **Team Value:** Agentic team should reduce bug count, not catalog it
113
+ * **Reference:** Knowledge Article #36 defines this standard
114
+
115
+ **USER TRUST DEPENDS ON FIXING BUGS, NOT CATALOGING THEM.**
116
+
94
117
  ### UNIVERSAL TECHNICAL CONSTRAINTS
95
118
  1. **Tool Formatting:** Do NOT use a colon before tool calls (e.g., write "Let me search" NOT "Let me search:").
96
119
  2. **URL Safety:** NEVER guess or hallucinate URLs. Use only known valid URLs.
@@ -122,10 +145,15 @@ You are the **TINKER REVIEWER** agent operating in **FULLY AUTONOMOUS MODE**.
122
145
  2. **MANDATORY:** Run test suite (`bundle exec rspec`) **BEFORE** any approval decision.
123
146
  3. **Detect missing specs:** Ensure file changes have corresponding tests.
124
147
  4. **Reject (Fail):** If tests fail or specs are missing, you MUST reject.
125
- 5. **Feedback:** Add `code_review` comments with findings.
126
- 6. **Transition:** Use `pass_audit` or `fail_audit`.
127
- 7. **Knowledge:** Search memory for project standards.
128
- 8. **Completion:** Mark idle after completing the review.
148
+ 5. **CRITICAL:** Reject PRs that document bugs without fixing them.
149
+ * Watch for: Tests marked "KNOWN BUG", "xfail", or similar that intentionally fail
150
+ * Watch for: PR comments describing unfixed issues instead of fixing them
151
+ * Watch for: TODO comments for bugs the worker could have fixed
152
+ * **Standard:** Workers MUST FIX bugs they discover, not catalog them (KB #36)
153
+ 6. **Feedback:** Add `code_review` comments with findings.
154
+ 7. **Transition:** Use `pass_audit` or `fail_audit`.
155
+ 8. **Knowledge:** Search memory for project standards.
156
+ 9. **Completion:** Mark idle after completing the review.
129
157
 
130
158
  ### FORBIDDEN ACTIONS (STRICT)
131
159
  * Do NOT implement new features or functionality.
@@ -198,7 +226,7 @@ You must use `create_proposal` to suggest actions.
198
226
  2. **Analysis & Memory**
199
227
  - Analyze patterns across tickets and code.
200
228
  - Store observations using `store_memory`.
201
- - Identify stale or incorrect memories and create `memory_cleanup` proposals.
229
+ - Identify stale or incorrect memories and delete them using `delete_memory` tool (autonomous cleanup).
202
230
 
203
231
  3. **Knowledge Base Maintenance**
204
232
  - **Consult First:** Always search for human instructions (`tags: instruction`) and architectural patterns before proposing changes.
@@ -218,7 +246,7 @@ You must use `create_proposal` to suggest actions.
218
246
  **Structure:**
219
247
  Every proposal must include:
220
248
  - `title`: Clear and concise.
221
- - `proposal_type`: One of [new_ticket, memory_cleanup, refactor, test_gap, feature].
249
+ - `proposal_type`: One of [new_ticket, refactor, test_gap, feature].
222
250
  - `reasoning`: Why this matters.
223
251
  - `confidence`: high/medium/low.
224
252
  - `priority`: high/medium/low.
@@ -226,7 +254,6 @@ Every proposal must include:
226
254
 
227
255
  **Types:**
228
256
  - `new_ticket`: Suggest a new task to be created.
229
- - `memory_cleanup`: Request deletion of stale/incorrect memories.
230
257
  - `refactor`: Identify code requiring refactoring.
231
258
  - `test_gap`: Find missing test coverage.
232
259
  - `feature`: Suggest new features or improvements.
@@ -237,7 +264,6 @@ Every proposal must include:
237
264
 
238
265
  **ABSOLUTELY FORBIDDEN:**
239
266
  - Modifying code, tickets, or files directly.
240
- - Deleting or modifying memories directly (use `memory_cleanup` proposal).
241
267
  - Writing, editing, or refactoring code.
242
268
  - Making git commits or pull requests.
243
269
  - Sending messages to other agents.
@@ -7,7 +7,7 @@ export DEBIAN_FRONTEND=noninteractive
7
7
  echo 'Acquire::Check-Date "false";' > /etc/apt/apt.conf.d/99no-check-date
8
8
 
9
9
  apt-get update && apt-get install -y \
10
- git curl tmux sudo unzip wget
10
+ git curl tmux sudo unzip wget jq nano
11
11
 
12
12
  # Install Node.js (required for Claude CLI)
13
13
  # Check for existing Node installation
@@ -11,10 +11,10 @@
11
11
  # - Dockerfile.sandbox in project root
12
12
  # - tinker.env.rb in project root (gitignored)
13
13
 
14
- require_relative "lib/tinker_agent/config"
15
- require_relative "lib/tinker_agent/docker"
16
- require_relative "lib/tinker_agent/agent"
17
- require_relative "agents"
14
+ require_relative "../lib/tinker_agent/config"
15
+ require_relative "../lib/tinker_agent/docker"
16
+ require_relative "../lib/tinker_agent/agent"
17
+ require_relative "../agents"
18
18
 
19
19
  def show_usage
20
20
  puts "Tinker Agent Runner"
@@ -39,9 +39,12 @@ if command == "attach"
39
39
  agent_type = ARGV[1]&.downcase
40
40
  abort "Usage: npx tinker-agent attach [agent-type]" unless agent_type
41
41
  config = TinkerAgent::Config.load
42
+ TinkerAgent::Config.fetch_repositories!(config)
42
43
  TinkerAgent::Agent.attach(agent_type, config, AGENT_CONFIGS)
43
44
  else
44
45
  config = TinkerAgent::Config.load
46
+ # Fetch repositories from Rails API before building
47
+ TinkerAgent::Config.fetch_repositories!(config)
45
48
  TinkerAgent::Docker.build_image(config)
46
49
  TinkerAgent::Agent.run(command, config, AGENT_CONFIGS)
47
50
  end
@@ -22,7 +22,26 @@ module TinkerAgent
22
22
  # Write banner to a persistent temp file (not auto-deleted)
23
23
  banner_path = "/tmp/tinker-agent-banner-#{agent_type}.txt"
24
24
  FileUtils.rm_rf(banner_path)
25
- File.write(banner_path, agent_def[:banner])
25
+
26
+ # Append repository context to banner
27
+ banner_content = agent_def[:banner].dup
28
+ if config["repositories"]&.any?
29
+ sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
30
+ repo_info = sorted_repos.map do |repo|
31
+ name = repo["name"]
32
+ path = repo["path_in_sandbox"] || "/workspace/#{name}"
33
+ " - **#{name}**: #{path}"
34
+ end.join("\n")
35
+
36
+ banner_content += "\n\n## REPOSITORY STRUCTURE\n\n"
37
+ banner_content += "This project has #{sorted_repos.count} repositories:\n\n"
38
+ banner_content += "#{repo_info}\n\n"
39
+ banner_content += "### Repository Switching\n\n"
40
+ banner_content += "Use `switch-to-repo.sh <name>` to change working directories:\n\n"
41
+ banner_content += "```bash\nswitch-to-repo.sh tinker\nswitch-to-repo.sh tinker-public\n```\n"
42
+ end
43
+
44
+ File.write(banner_path, banner_content)
26
45
 
27
46
  docker_cmd = build_docker_command(container_name, agent_type, config, agent_config, agent_def, banner_path)
28
47
 
@@ -134,6 +153,7 @@ module TinkerAgent
134
153
 
135
154
  add_github_auth!(docker_cmd, config)
136
155
  add_git_config!(docker_cmd, config)
156
+ add_repository_mounts!(docker_cmd, config)
137
157
  add_development_mounts!(docker_cmd)
138
158
 
139
159
  local_setup_script = File.join(File.dirname(__FILE__), "..", "..", "setup-agent.rb")
@@ -182,26 +202,67 @@ module TinkerAgent
182
202
  docker_cmd.concat(["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"]) if git_config["user_email"]
183
203
  end
184
204
 
205
+ def self.add_repository_mounts!(docker_cmd, config)
206
+ return unless config["repositories"].is_a?(Array) && config["repositories"].any?
207
+
208
+ puts "📚 Configuring #{config['repositories'].count} repositories..."
209
+
210
+ # Build REPOSITORIES_CONTEXT for sessionstart hook (format: "name:path,name:path")
211
+ sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
212
+ repos_context = sorted_repos.map do |repo|
213
+ repo_name = repo["name"]
214
+ sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
215
+ "#{repo_name}:#{sandbox_path}"
216
+ end.join(",")
217
+
218
+ docker_cmd.concat(["-e", "REPOSITORIES_CONTEXT=#{repos_context}"])
219
+
220
+ # Pass repository config via environment variables for container to clone
221
+ repo_config_json = sorted_repos.to_json
222
+ docker_cmd.concat(["-e", "REPOSITORIES_CONFIG=#{repo_config_json}"])
223
+
224
+ sorted_repos.each do |repo|
225
+ repo_name = repo["name"]
226
+ sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
227
+ puts " - #{repo_name}: #{sandbox_path} (will be cloned in container)"
228
+ end
229
+ end
230
+
185
231
  def self.add_development_mounts!(docker_cmd)
186
232
  # Check for local setup-agent.rb (for development)
187
233
  local_setup_script = File.join(File.dirname(__FILE__), "..", "..", "setup-agent.rb")
188
-
234
+
189
235
  # Check for local agent-bridge binaries (for development)
190
236
  arch = `uname -m`.strip
191
237
  linux_arch = (arch == "x86_64") ? "amd64" : "arm64"
192
- linux_bridge = File.join(Dir.pwd, "tinker-public", "bin", "agent-bridge-linux-#{linux_arch}")
193
-
238
+
239
+ # Try multiple possible paths for the binary (built Go binaries in dist/)
240
+ possible_paths = [
241
+ File.join(Dir.pwd, "overrides", "agent-bridge-linux-#{linux_arch}"), # TINKER_SANDBOX/overrides
242
+ File.join(Dir.pwd, "tinker-public", "dist", "agent-bridge-linux-#{linux_arch}"), # Running from metafolder
243
+ File.join(Dir.pwd, "..", "tinker-public", "dist", "agent-bridge-linux-#{linux_arch}"), # Running from TINKER_SANDBOX
244
+ File.join(Dir.pwd, "dist", "agent-bridge-linux-#{linux_arch}"), # Running from tinker-public
245
+ ]
246
+
247
+ linux_bridge = nil
248
+ possible_paths.each do |path|
249
+ if File.exist?(path)
250
+ linux_bridge = path
251
+ break
252
+ end
253
+ end
254
+
194
255
  local_bridge_default = File.join(Dir.pwd, "bin", "agent-bridge")
195
256
  local_tmux = File.join(File.dirname(__FILE__), "..", "..", "bin", "agent-bridge-tmux")
196
-
257
+
197
258
  if File.exist?(local_setup_script)
198
259
  puts "🔧 Using local setup-agent.rb for development"
199
260
  docker_cmd.concat(["-v", "#{File.expand_path(local_setup_script)}:/tmp/setup-agent.rb:ro"])
200
261
  end
201
262
 
202
- if File.exist?(linux_bridge)
263
+ if linux_bridge && File.exist?(linux_bridge)
203
264
  puts "🔧 Using local linux binary: #{linux_bridge}"
204
- docker_cmd.concat(["-v", "#{linux_bridge}:/tmp/agent-bridge:ro"])
265
+ docker_cmd.concat(["-v", "#{File.expand_path(linux_bridge)}:/tmp/agent-bridge:ro"])
205
266
  elsif File.exist?(local_bridge_default)
206
267
  # Check if it's a binary or script
207
268
  is_script = File.read(local_bridge_default, 4) == "#!/b"
@@ -212,7 +273,7 @@ module TinkerAgent
212
273
  docker_cmd.concat(["-v", "#{local_bridge_default}:/tmp/agent-bridge:ro"])
213
274
  end
214
275
  end
215
-
276
+
216
277
  if File.exist?(local_tmux)
217
278
  docker_cmd.concat(["-v", "#{File.expand_path(local_tmux)}:/tmp/agent-bridge-tmux:ro"])
218
279
  end
@@ -67,6 +67,56 @@ module TinkerAgent
67
67
  JSON.parse(JSON.generate(config))
68
68
  end
69
69
 
70
+ def self.fetch_repositories!(config)
71
+ return unless config["rails_api_url"] && config["project_id"]
72
+
73
+ # Use the first available agent's MCP API key
74
+ mcp_api_key = nil
75
+ if config["agents"].is_a?(Hash)
76
+ config["agents"].each do |_, agent_config|
77
+ if agent_config.is_a?(Hash) && agent_config["mcp_api_key"]
78
+ mcp_api_key = agent_config["mcp_api_key"]
79
+ break
80
+ end
81
+ end
82
+ end
83
+
84
+ return unless mcp_api_key
85
+
86
+ # Fetch repositories from Rails API
87
+ require "net/http"
88
+ require "json"
89
+ require "uri"
90
+
91
+ uri = URI("#{config['rails_api_url']}/projects/#{config['project_id']}/repositories")
92
+
93
+ begin
94
+ response = Net::HTTP.get_response(uri, { "X-API-Key" => mcp_api_key, "Accept" => "application/json" })
95
+
96
+ if response.code == "200"
97
+ repositories = JSON.parse(response.body)
98
+
99
+ if repositories.any?
100
+ config["repositories"] = repositories
101
+
102
+ # Build REPOSITORIES_CONTEXT string: "name:path,name:path"
103
+ repo_context = repositories.map { |r| "#{r['name']}:#{r['path_in_sandbox']}" }.join(",")
104
+ config["env"] ||= {}
105
+ config["env"]["REPOSITORIES_CONTEXT"] = repo_context
106
+
107
+ puts "📚 Loaded #{repositories.count} repositories for project #{config['project_id']}"
108
+ repositories.each do |repo|
109
+ puts " - #{repo['name']}: #{repo['path_in_sandbox']}"
110
+ end
111
+ end
112
+ else
113
+ puts "⚠️ Unable to fetch repositories (HTTP #{response.code})"
114
+ end
115
+ rescue StandardError => e
116
+ puts "⚠️ Failed to fetch repositories: #{e.message}"
117
+ end
118
+ end
119
+
70
120
  def self.image_name(config)
71
121
  if config["project_id"]
72
122
  "tinker-sandbox-#{config['project_id']}"
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.0.56",
3
+ "version": "1.0.59",
4
4
  "description": "Tinker Agent Runner",
5
5
  "bin": {
6
- "tinker-agent": "./run-tinker-agent.rb"
6
+ "tinker-agent": "./bin/run-tinker-agent.rb"
7
7
  },
8
8
  "files": [
9
- "run-tinker-agent.rb",
9
+ "bin/run-tinker-agent.rb",
10
10
  "agents.rb",
11
11
  "setup-agent.rb",
12
12
  "lib/",
@@ -18,4 +18,4 @@
18
18
  },
19
19
  "author": "",
20
20
  "license": "ISC"
21
- }
21
+ }
package/setup-agent.rb CHANGED
@@ -176,14 +176,24 @@ end
176
176
  def setup_claude_config!
177
177
  home_claude_json = File.expand_path("~/.claude.json")
178
178
 
179
+ # First, try to copy from mounted config (from host)
180
+ mounted_paths = ["/tmp/cfg/claude.json"]
181
+ mounted_paths.each do |path|
182
+ if File.exist?(path) && !File.exist?(home_claude_json)
183
+ FileUtils.cp(path, home_claude_json)
184
+ puts "📋 Copied claude.json from #{path}"
185
+ break
186
+ end
187
+ end
188
+
179
189
  if File.exist?(home_claude_json)
180
190
  puts "🔧 Configuring claude.json..."
181
191
  begin
182
192
  claude_config = JSON.parse(File.read(home_claude_json))
183
-
193
+
184
194
  # Add bypass permission at top level
185
195
  claude_config["bypassPermissionsModeAccepted"] = true
186
-
196
+
187
197
  File.write(home_claude_json, JSON.pretty_generate(claude_config))
188
198
  puts "✅ claude.json configured with bypass permissions"
189
199
  rescue JSON::ParserError
@@ -200,9 +210,33 @@ def setup_claude_settings!
200
210
  home_settings_json = File.join(settings_dir, "settings.json")
201
211
 
202
212
  puts "🔧 Configuring ~/.claude/settings.json..."
203
-
213
+
204
214
  settings_config = {}
205
- if File.exist?(home_settings_json)
215
+
216
+ # First, try to load user's existing settings from mounted config
217
+ # We merge both files: settings.json AND claude.json (for theme/appearance settings)
218
+ mounted_settings_paths = [
219
+ "/tmp/cfg/settings.json",
220
+ "/tmp/cfg/claude.json"
221
+ ]
222
+
223
+ mounted_settings = {}
224
+ mounted_settings_paths.each do |path|
225
+ if File.exist?(path)
226
+ begin
227
+ file_settings = JSON.parse(File.read(path))
228
+ # Merge settings - later files override earlier ones for conflicting keys
229
+ mounted_settings = mounted_settings.merge(file_settings)
230
+ puts " (loaded from #{path})"
231
+ rescue JSON::ParserError => e
232
+ puts " ⚠️ Failed to parse #{path}: #{e.message}"
233
+ end
234
+ end
235
+ end
236
+
237
+ if mounted_settings.any?
238
+ settings_config = mounted_settings
239
+ elsif File.exist?(home_settings_json)
206
240
  begin
207
241
  settings_config = JSON.parse(File.read(home_settings_json))
208
242
  rescue JSON::ParserError
@@ -266,6 +300,42 @@ def setup_claude_settings!
266
300
 
267
301
  settings_config["hooks"]["SessionStart"] << session_hook_to_add
268
302
 
303
+ # Install sessionstart-input.sh for repository context injection
304
+ # This provides repository information to Claude at session start
305
+ input_script_name = "sessionstart-input.sh"
306
+ input_dest_path = File.join(hooks_dir, input_script_name)
307
+
308
+ local_input_paths = [
309
+ File.join(Dir.pwd, "tinker-public", "hooks", input_script_name),
310
+ File.join(Dir.pwd, "hooks", input_script_name)
311
+ ]
312
+
313
+ local_input_content = nil
314
+ local_input_paths.each do |path|
315
+ if File.exist?(path)
316
+ local_input_content = File.read(path)
317
+ puts " (using local input hook: #{path})"
318
+ break
319
+ end
320
+ end
321
+
322
+ if local_input_content
323
+ File.write(input_dest_path, local_input_content)
324
+ File.chmod(0755, input_dest_path)
325
+ puts "✅ Installed SessionStart input hook (from local) to #{input_dest_path}"
326
+ else
327
+ puts "📥 Downloading SessionStart input hook..."
328
+ input_hook_url = "#{TINKER_RAW_URL}/hooks/#{input_script_name}"
329
+ begin
330
+ input_hook_content = URI.open(input_hook_url).read
331
+ File.write(input_dest_path, input_hook_content)
332
+ File.chmod(0755, input_dest_path)
333
+ puts "✅ Installed SessionStart input hook (from remote) to #{input_dest_path}"
334
+ rescue OpenURI::HTTPError => e
335
+ puts "⚠️ Failed to download input hook: #{e.message}"
336
+ end
337
+ end
338
+
269
339
  File.write(home_settings_json, JSON.pretty_generate(settings_config))
270
340
  puts "✅ ~/.claude/settings.json configured with skill hooks"
271
341
  end
@@ -277,6 +347,37 @@ def setup_system_prompt!
277
347
  puts "❌ /etc/tinker/system-prompt.txt not found!"
278
348
  exit 1
279
349
  end
350
+
351
+ # Add repository context to CLAUDE.md for easy reference
352
+ claude_md = File.expand_path("~/CLAUDE.md")
353
+ if ENV["REPOSITORIES_CONTEXT"]
354
+ repo_info = ENV["REPOSITORIES_CONTEXT"].split(',').map do |repo|
355
+ name, path = repo.split(':')
356
+ " - **#{name}**: #{path}"
357
+ end.join("\n")
358
+
359
+ repo_context_md = <<~MARKDOWN
360
+ ## Repository Structure
361
+
362
+ This project has #{ENV["REPOSITORIES_CONTEXT"].split(',').count} repositories:
363
+
364
+ #{repo_info}
365
+
366
+ ### Repository Switching
367
+
368
+ Use `switch-to-repo.sh <name>` to change working directories:
369
+
370
+ ```bash
371
+ switch-to-repo.sh tinker
372
+ switch-to-repo.sh tinker-public
373
+ ```
374
+ MARKDOWN
375
+
376
+ # Append to existing CLAUDE.md or create new one
377
+ existing_content = File.exist?(claude_md) ? File.read(claude_md) : ""
378
+ File.write(claude_md, existing_content + "\n" + repo_context_md)
379
+ puts "📝 Repository context added to ~/CLAUDE.md"
380
+ end
280
381
  end
281
382
 
282
383
 
@@ -424,34 +525,124 @@ def setup_github_auth!
424
525
 
425
526
  # Configure git
426
527
  system("git config --global credential.helper '!f() { test \"$1\" = get && echo \"protocol=https\" && echo \"host=github.com\" && echo \"username=x-access-token\" && echo \"password=$(#{helper_path})\"; }; f'")
427
-
428
- # Configure gh CLI wrapper for auto-refresh
429
- real_gh_path = "/usr/bin/gh"
430
- if File.exist?(real_gh_path)
431
- wrapper_path = "/usr/local/bin/gh"
432
- wrapper_content = <<~BASH
433
- #!/bin/bash
434
- # Auto-refresh GitHub token using git-auth-helper
435
- export GH_TOKEN=$(#{helper_path})
436
- exec #{real_gh_path} "$@"
437
- BASH
438
-
439
- File.write("/tmp/gh-wrapper", wrapper_content)
440
- system("sudo mv /tmp/gh-wrapper #{wrapper_path}")
441
- system("sudo chmod +x #{wrapper_path}")
442
- puts "✅ GitHub App authentication configured (with auto-refresh)"
443
- else
444
- puts "⚠️ Could not find 'gh' at #{real_gh_path}, skipping wrapper"
528
+
529
+ # Configure gh CLI wrapper for auto-refresh and permission controls
530
+ if install_gh_wrapper!(real_gh_path: "/usr/bin/gh", with_token_refresh: true, token_helper_path: helper_path)
531
+ puts "✅ GitHub App authentication configured (with auto-refresh + permission controls)"
445
532
  end
446
533
 
447
534
  elsif ENV["GH_TOKEN"] && !ENV["GH_TOKEN"].empty?
448
535
  system("echo '#{ENV['GH_TOKEN']}' | gh auth login --with-token 2>/dev/null")
449
- puts "🔐 GitHub authentication configured"
536
+
537
+ # Install wrapper for permission controls even with GH_TOKEN
538
+ if install_gh_wrapper!(real_gh_path: "/usr/bin/gh")
539
+ puts "🔐 GitHub authentication configured (with permission controls)"
540
+ else
541
+ puts "🔐 GitHub authentication configured"
542
+ end
450
543
  else
451
544
  puts "⚠️ No GH_TOKEN or GitHub App config - GitHub operations may fail"
452
545
  end
453
546
  end
454
547
 
548
+ def install_gh_wrapper!(real_gh_path:, with_token_refresh: false, token_helper_path: nil)
549
+ unless File.exist?(real_gh_path)
550
+ puts "⚠️ Could not find 'gh' at #{real_gh_path}, skipping wrapper"
551
+ return false
552
+ end
553
+
554
+ # Move real gh to gh.real if not already done
555
+ unless File.exist?("#{real_gh_path}.real")
556
+ system("sudo mv #{real_gh_path} #{real_gh_path}.real")
557
+ end
558
+
559
+ wrapper_path = "/usr/local/bin/gh"
560
+
561
+ # Build token export line if auto-refresh is enabled
562
+ token_export = with_token_refresh ? "export GH_TOKEN=$(#{token_helper_path})\n\n" : ""
563
+
564
+ wrapper_content = <<~BASH
565
+ #!/bin/bash
566
+ # GitHub CLI wrapper with permission controls for agents
567
+ #{token_export}# Check if caller is an agent (read AGENT_TYPE at runtime)
568
+ if [ -n "$AGENT_TYPE" ] && [[ "$AGENT_TYPE" =~ ^(worker|planner|reviewer|researcher)$ ]]; then
569
+ # Block comment-related commands for agents
570
+ if [[ "$1" == "comment" ]] || ([[ "$1" == "pr" ]] && [[ "$2" == "comment" ]]); then
571
+ echo "❌ You cannot use 'gh $*' commands as an agent." >&2
572
+ echo "" >&2
573
+ echo "Tinker has its own comment system via the add_comment MCP tool." >&2
574
+ echo "Please use the add_comment tool instead of gh commands." >&2
575
+ echo "" >&2
576
+ echo "Example:" >&2
577
+ echo " add_comment(ticket_id: 123, content: \\"Your comment here\\", comment_type: \\"note\\")" >&2
578
+ exit 1
579
+ fi
580
+ fi
581
+
582
+ # Execute real gh binary with all arguments
583
+ exec #{real_gh_path}.real "$@"
584
+ BASH
585
+
586
+ File.write("/tmp/gh-wrapper", wrapper_content)
587
+ system("sudo mv /tmp/gh-wrapper #{wrapper_path}")
588
+ system("sudo chmod +x #{wrapper_path}")
589
+ true
590
+ end
591
+
592
+ def clone_repositories!
593
+ return unless ENV["REPOSITORIES_CONFIG"]
594
+
595
+ begin
596
+ repos = JSON.parse(ENV["REPOSITORIES_CONFIG"])
597
+ return unless repos.is_a?(Array) && repos.any?
598
+
599
+ puts "📚 Cloning #{repos.count} repositories..."
600
+
601
+ repos.each do |repo|
602
+ repo_name = repo["name"]
603
+ github_url = repo["github_url"]
604
+ sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
605
+
606
+ # Create parent directory if needed
607
+ parent_dir = File.dirname(sandbox_path)
608
+ FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
609
+
610
+ # Clone repository if it doesn't exist
611
+ unless File.exist?(sandbox_path)
612
+ puts " 📥 Cloning #{repo_name} to #{sandbox_path}..."
613
+ system("git", "clone", "--depth", "1", github_url, sandbox_path)
614
+ else
615
+ puts " ✓ #{repo_name} already exists at #{sandbox_path}"
616
+ end
617
+ end
618
+
619
+ puts "✅ Repositories cloned"
620
+
621
+ # Write repository context to a file for hooks to read
622
+ # This is more reliable than environment variables in tmux sessions
623
+ if ENV["REPOSITORIES_CONTEXT"]
624
+ repo_context_file = File.expand_path("~/.claude/repos_context.txt")
625
+ File.write(repo_context_file, ENV["REPOSITORIES_CONTEXT"])
626
+ puts "📝 Repository context written to #{repo_context_file}"
627
+ end
628
+
629
+ # Export REPOSITORIES_CONTEXT to bash profile for shell sessions
630
+ if ENV["REPOSITORIES_CONTEXT"]
631
+ profile_file = File.expand_path("~/.bashrc")
632
+ existing_content = File.exist?(profile_file) ? File.read(profile_file) : ""
633
+
634
+ # Remove old export if present
635
+ existing_content.gsub!(/^export REPOSITORIES_CONTEXT=.*$/, "")
636
+
637
+ # Add new export at the end
638
+ File.write(profile_file, existing_content + "\nexport REPOSITORIES_CONTEXT=\"#{ENV['REPOSITORIES_CONTEXT']}\"\n")
639
+ puts "📝 REPOSITORIES_CONTEXT exported to ~/.bashrc"
640
+ end
641
+ rescue JSON::ParserError => e
642
+ puts "⚠️ Failed to parse REPOSITORIES_CONFIG: #{e.message}"
643
+ end
644
+ end
645
+
455
646
  def setup_git_config!
456
647
  # Configure identity if provided
457
648
  if ENV["GIT_USER_NAME"] && !ENV["GIT_USER_NAME"].empty?
@@ -514,6 +705,71 @@ def setup_git_hooks!
514
705
  puts "✅ Git hooks installed"
515
706
  end
516
707
 
708
+ def setup_multi_repo_scripts!
709
+ # Install switch-to-repo.sh script and repo-specific setup scripts
710
+ # These are only needed when using multi-repo setup
711
+ repos_dir = File.expand_path("~/repos")
712
+ bin_dir = "/usr/local/bin"
713
+
714
+ puts "📚 Setting up multi-repo scripts..."
715
+
716
+ # Create repos directory for repo-specific setup scripts
717
+ FileUtils.mkdir_p(repos_dir)
718
+
719
+ # Install switch-to-repo.sh script
720
+ switch_script = File.join(bin_dir, "switch-to-repo.sh")
721
+
722
+ # Try local copy first, then download from GitHub
723
+ local_paths = [
724
+ File.join(Dir.pwd, "tinker-public", "hooks", "switch-to-repo.sh"),
725
+ File.join(Dir.pwd, "hooks", "switch-to-repo.sh"),
726
+ "/tmp/hooks/switch-to-repo.sh"
727
+ ]
728
+
729
+ script_content = nil
730
+ local_paths.each do |path|
731
+ if File.exist?(path)
732
+ script_content = File.read(path)
733
+ puts " (from local: #{path})"
734
+ break
735
+ end
736
+ end
737
+
738
+ unless script_content
739
+ url = "#{TINKER_RAW_URL}/hooks/switch-to-repo.sh"
740
+ begin
741
+ script_content = URI.open(url).read
742
+ rescue OpenURI::HTTPError => e
743
+ puts "⚠️ Failed to download switch-to-repo.sh: #{e.message}"
744
+ return
745
+ end
746
+ end
747
+
748
+ # Write to temp file first, then sudo mv
749
+ temp_script = "/tmp/switch-to-repo.sh.temp"
750
+ File.write(temp_script, script_content)
751
+ system("sudo mv #{temp_script} #{switch_script}")
752
+ system("sudo chmod +x #{switch_script}")
753
+ puts "✅ Installed switch-to-repo.sh to #{bin_dir}"
754
+
755
+ # Install repo-specific setup scripts
756
+ # These are example scripts that can be customized per repository
757
+ local_repos_dir = File.join(Dir.pwd, "tinker-public", "repos")
758
+
759
+ if File.directory?(local_repos_dir)
760
+ Dir.glob(File.join(local_repos_dir, "*.sh")).each do |script_file|
761
+ script_name = File.basename(script_file)
762
+ dest_script = File.join(repos_dir, script_name)
763
+ FileUtils.cp(script_file, dest_script)
764
+ File.chmod(0755, dest_script)
765
+ puts " - Installed #{script_name}"
766
+ end
767
+ puts "✅ Installed repo-specific setup scripts"
768
+ else
769
+ puts "ℹ️ No repo-specific setup scripts found (skipping)"
770
+ end
771
+ end
772
+
517
773
  def download_agent_bridge!
518
774
  # Detect architecture
519
775
  arch = `uname -m`.strip
@@ -526,20 +782,109 @@ def download_agent_bridge!
526
782
  exit 1
527
783
  end
528
784
 
529
- bridge_url = "#{TINKER_RAW_URL}/bin/agent-bridge-linux-#{arch}"
530
- bridge_tmux_url = "#{TINKER_RAW_URL}/bin/agent-bridge-tmux"
531
785
  target_dir = "/usr/local/bin"
532
786
 
533
- # Check if binaries are mounted at /tmp (dev mode)
787
+ # 1. Check for local override in /tmp/overrides (development mode)
788
+ override_dir = "/tmp/overrides"
789
+ if Dir.exist?(override_dir)
790
+ local_override = File.join(override_dir, "agent-bridge-linux-#{arch}")
791
+ if File.exist?(local_override)
792
+ puts "🔧 Using local override: #{local_override}"
793
+ system("sudo cp #{local_override} #{target_dir}/agent-bridge")
794
+ system("sudo chmod +x #{target_dir}/agent-bridge")
795
+ puts "✅ agent-bridge installed from local override"
796
+ return download_agent_bridge_tmux!(target_dir)
797
+ end
798
+ end
799
+
800
+ # 2. Check if binaries are mounted at /tmp (dev mode)
534
801
  if File.exist?("/tmp/agent-bridge")
535
802
  puts "🔧 Installing mounted agent-bridge..."
536
803
  system("sudo cp /tmp/agent-bridge #{target_dir}/agent-bridge")
537
804
  system("sudo chmod +x #{target_dir}/agent-bridge")
538
- else
539
- puts "📥 Downloading agent-bridge for linux-#{arch}..."
540
- system("sudo curl -fsSL #{bridge_url} -o #{target_dir}/agent-bridge")
805
+ puts "✅ agent-bridge installed from mount"
806
+ return download_agent_bridge_tmux!(target_dir)
807
+ end
808
+
809
+ # 3. Try Civo bucket download (production mode)
810
+ if ENV["CIVO_ACCESS_KEY_ID"] && ENV["CIVO_SECRET_KEY"]
811
+ puts "📥 Downloading agent-bridge from Civo bucket..."
812
+ if download_from_civo!(arch, target_dir)
813
+ return download_agent_bridge_tmux!(target_dir)
814
+ else
815
+ puts "⚠️ Civo download failed, falling back to GitHub..."
816
+ end
817
+ end
818
+
819
+ # 4. Fallback to GitHub raw URLs (legacy)
820
+ bridge_url = "#{TINKER_RAW_URL}/bin/agent-bridge-linux-#{arch}"
821
+ puts "📥 Downloading agent-bridge for linux-#{arch} from GitHub..."
822
+ system("sudo curl -fsSL #{bridge_url} -o #{target_dir}/agent-bridge")
823
+ system("sudo chmod +x #{target_dir}/agent-bridge")
824
+
825
+ puts "✅ agent-bridge installed to #{target_dir}"
826
+ download_agent_bridge_tmux!(target_dir)
827
+ end
828
+
829
+ def download_from_civo!(arch, target_dir)
830
+ require "aws-sdk-s3"
831
+
832
+ region = ENV["CIVO_REGION"] || "LON1"
833
+ endpoint = ENV["CIVO_ENDPOINT"] || "https://objectstore.fra1.civo.com"
834
+ bucket = ENV["TINKER_BINARIES_BUCKET"] || "tinker"
835
+ version = ENV["AGENT_BRIDGE_VERSION"] || "latest"
836
+
837
+ binary_name = "agent-bridge-linux-#{arch}"
838
+ key = "agent-bridge/#{version}/#{binary_name}"
839
+
840
+ puts " Region: #{region}"
841
+ puts " Bucket: #{bucket}"
842
+ puts " Version: #{version}"
843
+ puts " Binary: #{binary_name}"
844
+
845
+ begin
846
+ # Configure Civo S3-compatible client
847
+ Aws.config.update({
848
+ region: region,
849
+ endpoint: endpoint,
850
+ access_key_id: ENV["CIVO_ACCESS_KEY_ID"],
851
+ secret_access_key: ENV["CIVO_SECRET_KEY"]
852
+ })
853
+
854
+ s3 = Aws::S3::Resource.new
855
+ obj = s3.bucket(bucket).object(key)
856
+
857
+ # Generate presigned URL (valid for 1 hour)
858
+ url = obj.presigned_url(:get, expires_in: 3600)
859
+
860
+ puts "✅ Generated presigned URL"
861
+
862
+ # Download via curl
863
+ system("sudo curl -fsSL #{url} -o #{target_dir}/agent-bridge")
864
+
865
+ unless $?.success?
866
+ puts "❌ Failed to download agent-bridge from Civo"
867
+ return false
868
+ end
869
+
541
870
  system("sudo chmod +x #{target_dir}/agent-bridge")
871
+ puts "✅ Downloaded agent-bridge from Civo"
872
+ return true
873
+
874
+ rescue LoadError
875
+ puts "⚠️ aws-sdk-s3 gem not installed"
876
+ return false
877
+ rescue Aws::S3::Errors::NoSuchKey
878
+ puts "❌ Version #{version} not found in bucket"
879
+ return false
880
+ rescue => e
881
+ puts "❌ Civo download failed: #{e.message}"
882
+ return false
542
883
  end
884
+ end
885
+
886
+ def download_agent_bridge_tmux!(target_dir)
887
+ bridge_tmux_url = "#{TINKER_RAW_URL}/bin/agent-bridge-tmux"
543
888
 
544
889
  if File.exist?("/tmp/agent-bridge-tmux")
545
890
  puts "🔧 Installing mounted agent-bridge-tmux..."
@@ -549,8 +894,6 @@ def download_agent_bridge!
549
894
  system("sudo curl -fsSL #{bridge_tmux_url} -o #{target_dir}/agent-bridge-tmux")
550
895
  system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
551
896
  end
552
-
553
- puts "✅ agent-bridge installed to #{target_dir}"
554
897
 
555
898
  # Patch agent-bridge-tmux to force INSIDE_TMUX=1
556
899
  # Note: This is now fixed in the repo, but we keep this for backward compatibility
@@ -602,14 +945,17 @@ puts ""
602
945
  check_env!
603
946
  fetch_agent_id!
604
947
  setup_mcp_config!
948
+ setup_system_prompt!
949
+ # Clone repositories BEFORE setting up hooks that depend on them
950
+ setup_github_auth!
951
+ setup_git_config!
952
+ clone_repositories!
605
953
  setup_claude_config!
606
954
  setup_claude_settings!
607
- setup_system_prompt!
608
955
  # setup_skill_hooks! # Deprecated in favor of global hooks
609
956
  setup_skills!
610
- setup_github_auth!
611
- setup_git_config!
612
957
  setup_git_hooks!
958
+ setup_multi_repo_scripts!
613
959
  # prepare_git_state! is now in entrypoint.sh
614
960
  bin_dir = download_agent_bridge!
615
961
  run_agent!(bin_dir)