coolhand 0.5.0 → 0.6.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 +4 -4
- data/AGENTS.harness.md +139 -0
- data/CHANGELOG.md +64 -0
- data/README.md +46 -105
- data/docs/configuration.md +40 -0
- data/docs/openai.md +91 -0
- data/docs/template-search.md +218 -0
- data/docs/vertex.md +54 -0
- data/lib/coolhand/api_service.rb +56 -16
- data/lib/coolhand/base_interceptor.rb +58 -38
- data/lib/coolhand/configuration.rb +39 -6
- data/lib/coolhand/default_exclude_api_patterns.yml +3 -2
- data/lib/coolhand/default_intercept_addresses.yml +10 -5
- data/lib/coolhand/default_intercept_path_patterns.yml +12 -0
- data/lib/coolhand/errors.rb +19 -0
- data/lib/coolhand/logger_service.rb +1 -1
- data/lib/coolhand/net_http_interceptor.rb +154 -24
- data/lib/coolhand/open_ai/batch_result_processor.rb +3 -1
- data/lib/coolhand/open_ai/webhook_id_store.rb +45 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +50 -12
- data/lib/coolhand/pagination.rb +82 -0
- data/lib/coolhand/read_requests.rb +77 -0
- data/lib/coolhand/template_service.rb +76 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +97 -40
- data/lib/coolhand/webhook_interceptor.rb +11 -0
- data/lib/coolhand.rb +10 -5
- metadata +16 -9
- data/.claude/skills/loop-review/SKILL.md +0 -112
- data/.claude/skills/prep-release/SKILL.md +0 -160
- data/.idea/coolhand-ruby.iml +0 -6
- data/CLAUDE.md +0 -47
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "api_service"
|
|
5
|
+
require_relative "pagination"
|
|
6
|
+
|
|
7
|
+
module Coolhand
|
|
8
|
+
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
|
+
TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# Read-only. Requires the client's **private** key — the public key is write-only here and is
|
|
12
|
+
# rejected like an invalid one.
|
|
13
|
+
#
|
|
14
|
+
# Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
|
|
15
|
+
# See docs/template-search.md.
|
|
16
|
+
class TemplateService < ApiService
|
|
17
|
+
ERROR_NOUN = "Template"
|
|
18
|
+
BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
super("v2/llm_request_templates")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Filters, error semantics and server behaviour: docs/template-search.md.
|
|
25
|
+
def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
|
|
26
|
+
include_system: nil, page: nil, per: nil)
|
|
27
|
+
query = {
|
|
28
|
+
search: search,
|
|
29
|
+
workload_id: workload_id,
|
|
30
|
+
status: status,
|
|
31
|
+
include_deprecated: include_deprecated,
|
|
32
|
+
include_system: include_system,
|
|
33
|
+
page: page,
|
|
34
|
+
per: per
|
|
35
|
+
}.compact
|
|
36
|
+
|
|
37
|
+
templates, response = get_json_with_headers(list_url(query), ERROR_NOUN)
|
|
38
|
+
raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
|
|
39
|
+
|
|
40
|
+
TemplateSearchResult.new(
|
|
41
|
+
templates: templates,
|
|
42
|
+
pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
|
|
43
|
+
).freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
|
|
47
|
+
# list reaches deprecated and system templates by id with no opt-in flag.
|
|
48
|
+
def get_template(id)
|
|
49
|
+
get_json(resource_url(id), ERROR_NOUN)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def list_url(query)
|
|
55
|
+
uri = URI.parse(api_endpoint)
|
|
56
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
57
|
+
uri
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resource_url(id)
|
|
61
|
+
raise Error, BLANK_ID_MESSAGE unless id.is_a?(String)
|
|
62
|
+
|
|
63
|
+
trimmed = id.strip
|
|
64
|
+
# A blank id resolves to the index route (bare array, not one template); a bare dot segment
|
|
65
|
+
# retargets the request at another path. Neither would 404, so both are rejected here.
|
|
66
|
+
raise Error, BLANK_ID_MESSAGE if trimmed.empty? || [".", ".."].include?(trimmed)
|
|
67
|
+
|
|
68
|
+
URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
|
|
72
|
+
def escape_path_segment(value)
|
|
73
|
+
URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/coolhand/version.rb
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../coolhand"
|
|
4
|
+
|
|
3
5
|
module Coolhand
|
|
4
6
|
module Vertex
|
|
5
7
|
class BatchResultProcessor
|
|
6
|
-
|
|
8
|
+
# Global endpoint, not the job's region-specific one — matches the
|
|
9
|
+
# "aiplatform.googleapis.com" entry in default_intercept_addresses.yml
|
|
10
|
+
# so backend URL-shape classification stays consistent with the rest
|
|
11
|
+
# of the gem's Vertex traffic. The URL this produces always contains
|
|
12
|
+
# "/batchPredictionJobs/", which is also the default client-side
|
|
13
|
+
# exclude_api_patterns entry — that's harmless here since this class
|
|
14
|
+
# sends via BaseInterceptor/ApiService directly and never goes through
|
|
15
|
+
# NetHttpInterceptor's intercept/exclude filtering.
|
|
16
|
+
VERTEX_API_BASE_URL = "https://aiplatform.googleapis.com/v1/"
|
|
17
|
+
SOURCE_API = "vertex"
|
|
18
|
+
VALID_NAME_PATTERN = %r{\Aprojects/[^/?#\s]+/locations/[^/?#\s]+/batchPredictionJobs/[^/?#\s]+\z}
|
|
19
|
+
|
|
20
|
+
attr_reader :batch_info, :model
|
|
7
21
|
|
|
8
|
-
def initialize(batch_info:)
|
|
22
|
+
def initialize(batch_info:, model: nil)
|
|
9
23
|
@batch_info = batch_info
|
|
24
|
+
@model = model
|
|
10
25
|
end
|
|
11
26
|
|
|
12
27
|
def call(batch_results = [])
|
|
@@ -16,7 +31,7 @@ module Coolhand
|
|
|
16
31
|
when "JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
|
|
17
32
|
Rails.logger.info("[Interceptor] Vertex batch #{batch_info} still processing")
|
|
18
33
|
when "JOB_STATE_SUCCEEDED"
|
|
19
|
-
|
|
34
|
+
process_completed_batch(batch_results)
|
|
20
35
|
when "JOB_STATE_FAILED"
|
|
21
36
|
handle_failed_batch
|
|
22
37
|
else
|
|
@@ -28,56 +43,98 @@ module Coolhand
|
|
|
28
43
|
|
|
29
44
|
private
|
|
30
45
|
|
|
31
|
-
def process_completed_batch(
|
|
32
|
-
|
|
46
|
+
def process_completed_batch(batch_results)
|
|
47
|
+
name = batch_info["name"].to_s.sub(%r{\A/+}, "")
|
|
48
|
+
unless name.match?(VALID_NAME_PATTERN)
|
|
49
|
+
Rails.logger.error("[Interceptor] Vertex batch #{batch_info['displayName']} has a missing or " \
|
|
50
|
+
"invalid job resource name (#{batch_info['name'].inspect}); skipping request log")
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
start_time = Time.iso8601(batch_info["startTime"])
|
|
56
|
+
end_time = Time.iso8601(batch_info["endTime"])
|
|
57
|
+
rescue TypeError, ArgumentError
|
|
58
|
+
Rails.logger.error("[Interceptor] Vertex batch #{batch_info['displayName']} has a missing or " \
|
|
59
|
+
"invalid startTime/endTime; skipping request log")
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if end_time < start_time
|
|
64
|
+
Rails.logger.warn("[Interceptor] Vertex batch #{batch_info['displayName']} has an endTime before " \
|
|
65
|
+
"its startTime; sending request log(s) with duration_ms clamped to 0")
|
|
66
|
+
end_time = start_time
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
duration_ms = ((end_time - start_time) * 1000).to_i
|
|
70
|
+
url = "#{VERTEX_API_BASE_URL}#{name}"
|
|
71
|
+
resolved = resolved_model
|
|
72
|
+
|
|
73
|
+
sent = batch_results.count do |batch_item|
|
|
74
|
+
send_item_log(batch_item, url: url, model: resolved, start_time: start_time, end_time: end_time,
|
|
75
|
+
duration_ms: duration_ms)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# "Sent" here means dispatched to BaseInterceptor without a local
|
|
79
|
+
# error (e.g. a malformed batch_item) — BaseInterceptor swallows any
|
|
80
|
+
# downstream delivery failure itself, so this can't confirm the
|
|
81
|
+
# Coolhand API actually received the log.
|
|
82
|
+
if sent == batch_results.size
|
|
83
|
+
Rails.logger.info("[Interceptor] Dispatched #{sent} result(s) for Vertex batch " \
|
|
84
|
+
"#{batch_info['displayName']} for logging")
|
|
85
|
+
else
|
|
86
|
+
Rails.logger.warn("[Interceptor] Dispatched #{sent}/#{batch_results.size} result(s) for Vertex " \
|
|
87
|
+
"batch #{batch_info['displayName']} for logging — " \
|
|
88
|
+
"#{batch_results.size - sent} item(s) were malformed and skipped")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def send_item_log(batch_item, url:, model:, start_time:, end_time:, duration_ms:)
|
|
93
|
+
unless batch_item.is_a?(Hash) && (batch_item.key?("request") || batch_item.key?("response"))
|
|
94
|
+
Rails.logger.error("[Interceptor] Vertex batch #{batch_info['displayName']} has a malformed result " \
|
|
95
|
+
"item (#{batch_item.class}); skipping request log")
|
|
96
|
+
return false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
BaseInterceptor.send_complete_request_log(
|
|
100
|
+
request_id: SecureRandom.hex(16),
|
|
33
101
|
method: "POST",
|
|
34
|
-
url:
|
|
102
|
+
url: url,
|
|
103
|
+
source_api: SOURCE_API,
|
|
104
|
+
model: model,
|
|
105
|
+
request_headers: {},
|
|
35
106
|
request_body: batch_item["request"],
|
|
107
|
+
response_headers: {},
|
|
36
108
|
response_body: batch_item["response"],
|
|
37
109
|
status_code: 200,
|
|
38
|
-
start_time:
|
|
39
|
-
end_time:
|
|
40
|
-
|
|
41
|
-
|
|
110
|
+
start_time: start_time,
|
|
111
|
+
end_time: end_time,
|
|
112
|
+
duration_ms: duration_ms,
|
|
113
|
+
is_streaming: false
|
|
114
|
+
)
|
|
115
|
+
true
|
|
42
116
|
rescue StandardError => e
|
|
43
117
|
Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
|
|
118
|
+
false
|
|
44
119
|
end
|
|
45
120
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
end_time = Time.iso8601(end_time)
|
|
50
|
-
duration_ms = ((end_time - start_time) * 1000).to_i
|
|
121
|
+
def resolved_model
|
|
122
|
+
candidate = [model, batch_info["model"]].find { |c| Coolhand.required_field?(c) }&.to_s&.strip
|
|
123
|
+
return if candidate.nil?
|
|
51
124
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
request_body: request_body,
|
|
60
|
-
response_headers: {},
|
|
61
|
-
response_body: response_body,
|
|
62
|
-
status_code: status_code,
|
|
63
|
-
duration_ms: duration_ms,
|
|
64
|
-
completed_at: end_time,
|
|
65
|
-
is_streaming: false
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
api_service = Coolhand::ApiService.new
|
|
70
|
-
api_service.send_llm_request_log(request_data)
|
|
71
|
-
|
|
72
|
-
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
73
|
-
rescue StandardError => e
|
|
74
|
-
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
125
|
+
# Only normalize genuine Vertex model resource paths (e.g.
|
|
126
|
+
# "publishers/google/models/gemini-2.0-flash" or
|
|
127
|
+
# "projects/P/locations/L/models/M@1") down to their bare id. A
|
|
128
|
+
# caller-supplied `model:` might itself be a provider-qualified slug
|
|
129
|
+
# that happens to contain a slash (e.g. "meta-llama/Llama-3") —
|
|
130
|
+
# leave anything that isn't a resource path untouched.
|
|
131
|
+
candidate.match?(%r{\A(publishers|projects)/}) ? candidate.split("/").last : candidate
|
|
75
132
|
end
|
|
76
133
|
|
|
77
134
|
# TODO: implement API to handle failed batch results and display errors on dashboard page
|
|
78
135
|
def handle_failed_batch
|
|
79
|
-
|
|
80
|
-
|
|
136
|
+
message = batch_info["error"].is_a?(Hash) ? batch_info["error"]["message"] : batch_info["error"]
|
|
137
|
+
Rails.logger.error("[Interceptor] Vertex batch for #{batch_info['displayName']} failed: #{message}")
|
|
81
138
|
end
|
|
82
139
|
end
|
|
83
140
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "open_ai/webhook_validator"
|
|
4
|
+
require_relative "open_ai/batch_result_processor"
|
|
5
|
+
|
|
3
6
|
module Coolhand
|
|
4
7
|
module WebhookInterceptor
|
|
5
8
|
def intercept_batch_request
|
|
@@ -14,10 +17,18 @@ module Coolhand
|
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
payload = JSON.parse(@validator.payload)
|
|
20
|
+
raise TypeError, "webhook payload must be a JSON object, got #{payload.class}" unless payload.is_a?(Hash)
|
|
17
21
|
|
|
18
22
|
process_event(payload)
|
|
19
23
|
rescue StandardError => e
|
|
24
|
+
# Fail closed: any error here (malformed payload, a bug in
|
|
25
|
+
# process_event/BatchResultProcessor, etc.) must still halt the
|
|
26
|
+
# before_action chain. Falling through without calling `head` would
|
|
27
|
+
# let the controller action run for a request whose webhook
|
|
28
|
+
# signature was never confirmed valid.
|
|
20
29
|
Rails.logger.error("[Interceptor] Failed to intercept batch request: #{e.message}")
|
|
30
|
+
head :unauthorized
|
|
31
|
+
false
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
def webhook_secret
|
data/lib/coolhand.rb
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "uri"
|
|
4
|
-
require "faraday"
|
|
5
4
|
require "securerandom"
|
|
6
5
|
require "json"
|
|
7
6
|
require "base64"
|
|
7
|
+
require "time"
|
|
8
8
|
|
|
9
|
+
require_relative "coolhand/errors"
|
|
9
10
|
require_relative "coolhand/version"
|
|
10
11
|
require_relative "coolhand/configuration"
|
|
11
12
|
require_relative "coolhand/collector"
|
|
@@ -14,13 +15,12 @@ require_relative "coolhand/net_http_interceptor"
|
|
|
14
15
|
require_relative "coolhand/api_service"
|
|
15
16
|
require_relative "coolhand/logger_service"
|
|
16
17
|
require_relative "coolhand/feedback_service"
|
|
18
|
+
require_relative "coolhand/template_service"
|
|
17
19
|
require_relative "coolhand/webhook_interceptor"
|
|
18
20
|
|
|
19
21
|
# The main module for the Coolhand gem.
|
|
20
22
|
# It provides the configuration interface and initializes the patching.
|
|
21
23
|
module Coolhand
|
|
22
|
-
class Error < StandardError; end
|
|
23
|
-
|
|
24
24
|
# Class-level instance variables to hold the configuration
|
|
25
25
|
@configuration = Configuration.new
|
|
26
26
|
|
|
@@ -62,12 +62,11 @@ module Coolhand
|
|
|
62
62
|
|
|
63
63
|
return yield unless configuration.enabled
|
|
64
64
|
|
|
65
|
-
patched = NetHttpInterceptor.patched?
|
|
66
65
|
NetHttpInterceptor.patch!
|
|
67
66
|
begin
|
|
68
67
|
yield
|
|
69
68
|
ensure
|
|
70
|
-
NetHttpInterceptor.unpatch!
|
|
69
|
+
NetHttpInterceptor.unpatch!
|
|
71
70
|
end
|
|
72
71
|
end
|
|
73
72
|
|
|
@@ -104,6 +103,12 @@ module Coolhand
|
|
|
104
103
|
LoggerService.new
|
|
105
104
|
end
|
|
106
105
|
|
|
106
|
+
# Creates a new TemplateService instance, for reading LLM request templates back out of
|
|
107
|
+
# Coolhand. Needs the private API key - the public key is write-only on this API.
|
|
108
|
+
def template_service
|
|
109
|
+
TemplateService.new
|
|
110
|
+
end
|
|
111
|
+
|
|
107
112
|
def required_field?(value)
|
|
108
113
|
return false if value.nil?
|
|
109
114
|
return false if value.respond_to?(:empty?) && value.empty?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coolhand
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Carroll
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
12
|
+
date: 2026-09-13 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: base64
|
|
@@ -26,23 +26,21 @@ dependencies:
|
|
|
26
26
|
- !ruby/object:Gem::Version
|
|
27
27
|
version: '0.2'
|
|
28
28
|
description: Automatically intercept and log LLM requests from Ruby applications.
|
|
29
|
-
Supports OpenAI, official Anthropic gem, ruby-anthropic gem,
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
Supports OpenAI, official Anthropic gem, ruby-anthropic gem, Google Gemini, Google
|
|
30
|
+
Vertex AI, AWS Bedrock, OpenRouter, and any other library using Net::HTTP (directly,
|
|
31
|
+
or via Faraday's default adapter). Features a single unified Net::HTTP interceptor,
|
|
32
|
+
streaming support, thread-safe operation, and automatic duplicate request prevention.
|
|
32
33
|
email:
|
|
33
34
|
- mc@coolhandlabs.com
|
|
34
35
|
executables: []
|
|
35
36
|
extensions: []
|
|
36
37
|
extra_rdoc_files: []
|
|
37
38
|
files:
|
|
38
|
-
- ".claude/skills/loop-review/SKILL.md"
|
|
39
|
-
- ".claude/skills/prep-release/SKILL.md"
|
|
40
|
-
- ".idea/coolhand-ruby.iml"
|
|
41
39
|
- ".rspec"
|
|
42
40
|
- ".rubocop.yml"
|
|
43
41
|
- ".simplecov"
|
|
42
|
+
- AGENTS.harness.md
|
|
44
43
|
- CHANGELOG.md
|
|
45
|
-
- CLAUDE.md
|
|
46
44
|
- LICENSE
|
|
47
45
|
- README.md
|
|
48
46
|
- Rakefile
|
|
@@ -51,6 +49,9 @@ files:
|
|
|
51
49
|
- docs/configuration.md
|
|
52
50
|
- docs/elevenlabs.md
|
|
53
51
|
- docs/feedback.md
|
|
52
|
+
- docs/openai.md
|
|
53
|
+
- docs/template-search.md
|
|
54
|
+
- docs/vertex.md
|
|
54
55
|
- lib/coolhand.rb
|
|
55
56
|
- lib/coolhand/api_service.rb
|
|
56
57
|
- lib/coolhand/base_interceptor.rb
|
|
@@ -58,11 +59,17 @@ files:
|
|
|
58
59
|
- lib/coolhand/configuration.rb
|
|
59
60
|
- lib/coolhand/default_exclude_api_patterns.yml
|
|
60
61
|
- lib/coolhand/default_intercept_addresses.yml
|
|
62
|
+
- lib/coolhand/default_intercept_path_patterns.yml
|
|
63
|
+
- lib/coolhand/errors.rb
|
|
61
64
|
- lib/coolhand/feedback_service.rb
|
|
62
65
|
- lib/coolhand/logger_service.rb
|
|
63
66
|
- lib/coolhand/net_http_interceptor.rb
|
|
64
67
|
- lib/coolhand/open_ai/batch_result_processor.rb
|
|
68
|
+
- lib/coolhand/open_ai/webhook_id_store.rb
|
|
65
69
|
- lib/coolhand/open_ai/webhook_validator.rb
|
|
70
|
+
- lib/coolhand/pagination.rb
|
|
71
|
+
- lib/coolhand/read_requests.rb
|
|
72
|
+
- lib/coolhand/template_service.rb
|
|
66
73
|
- lib/coolhand/version.rb
|
|
67
74
|
- lib/coolhand/vertex/batch_result_processor.rb
|
|
68
75
|
- lib/coolhand/webhook_interceptor.rb
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: loop-review
|
|
3
|
-
description: |
|
|
4
|
-
Iteratively runs code review against the current diff, applies fixes, and
|
|
5
|
-
re-reviews until a round comes back clean (or a safety cap is hit). Use
|
|
6
|
-
when the user types /loop-review, asks to "loop the review", "review
|
|
7
|
-
until clean", "keep reviewing and fixing until nothing's left", or wants
|
|
8
|
-
a self-healing code review cycle instead of a single one-shot pass.
|
|
9
|
-
user_invocable: true
|
|
10
|
-
version: 0.3.0
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
# Loop Review
|
|
14
|
-
|
|
15
|
-
This skill runs `/code-review` repeatedly against the working diff, applies
|
|
16
|
-
fixes between rounds, and re-reviews until a round finds nothing new or a
|
|
17
|
-
safety cap is reached. Use it after a merge, a large diff, or whenever a
|
|
18
|
-
single review pass isn't enough to converge on a clean state.
|
|
19
|
-
|
|
20
|
-
## Scope
|
|
21
|
-
|
|
22
|
-
Default scope is the diff between the current branch and its merge-base
|
|
23
|
-
with the repo's base branch (`git diff $(git merge-base origin/main
|
|
24
|
-
HEAD)...HEAD`, or `origin/main` substituted with whatever base branch
|
|
25
|
-
applies). If `$ARGUMENTS` names a path or a narrower scope, review only
|
|
26
|
-
that instead of the full diff.
|
|
27
|
-
|
|
28
|
-
This skill is deliberately diff-scoped. For a whole-package audit before a
|
|
29
|
-
release (full-codebase security red-team, docs review, everything since
|
|
30
|
-
the last tag) use `/prep-release` instead.
|
|
31
|
-
|
|
32
|
-
`$ARGUMENTS` may also contain:
|
|
33
|
-
- An effort level to pass through to `/code-review` (`low`/`medium`/
|
|
34
|
-
`high`/`high→max`/`ultra`). Default: `medium`.
|
|
35
|
-
- A round cap override, e.g. `--max-rounds 3`. Default: 5.
|
|
36
|
-
|
|
37
|
-
## The round loop
|
|
38
|
-
|
|
39
|
-
1. Invoke `/code-review <effort> --fix` (via the `Skill` tool) against the
|
|
40
|
-
current scope.
|
|
41
|
-
2. **Dry round (0 findings) → converged.** Stop and move to verification.
|
|
42
|
-
3. **Findings found and fixed** → do not declare victory yet. Run another
|
|
43
|
-
round to confirm the fixes didn't introduce a regression and that
|
|
44
|
-
nothing was missed.
|
|
45
|
-
4. **No-progress detection**: if two consecutive rounds return the same
|
|
46
|
-
non-empty set of findings, `--fix` isn't resolving them mechanically
|
|
47
|
-
(likely a design/architecture call that needs a human). Stop looping,
|
|
48
|
-
list the stuck findings, and hand them to the user instead of retrying
|
|
49
|
-
forever.
|
|
50
|
-
5. **Safety cap**: if the round cap is reached without converging or
|
|
51
|
-
getting stuck, stop and report the remaining findings — don't loop
|
|
52
|
-
silently past the cap.
|
|
53
|
-
|
|
54
|
-
Each round's fixes should stay reviewable: don't squash multiple rounds
|
|
55
|
-
into one silent edit. Note per-round changes in the final summary so the
|
|
56
|
-
user can inspect them with `git diff`.
|
|
57
|
-
|
|
58
|
-
## Review criteria
|
|
59
|
-
|
|
60
|
-
Beyond whatever `/code-review` already checks for correctness bugs and
|
|
61
|
-
reuse/simplification/efficiency, every round in a Ruby gem repo like this
|
|
62
|
-
one should also flag:
|
|
63
|
-
|
|
64
|
-
- **Ruby idiom / DRY**: semantic, expressive naming; no duplicated logic
|
|
65
|
-
that should be extracted into a shared method; idiomatic use of
|
|
66
|
-
Ruby/Enumerable over manual loops where it reads better; no needless
|
|
67
|
-
boilerplate.
|
|
68
|
-
- **Gem publishing discipline**: don't break public interfaces unless
|
|
69
|
-
necessary.
|
|
70
|
-
- If a break is necessary, it must come with: a `CHANGELOG.md` entry in
|
|
71
|
-
Keep a Changelog format (this repo already follows that format — match
|
|
72
|
-
the style of existing entries, e.g. plain-English migration notes like
|
|
73
|
-
the `0.5.0` entry) and a version bump in `lib/coolhand/version.rb` that
|
|
74
|
-
matches SemVer (patch = fix, minor = backward-compatible addition,
|
|
75
|
-
minor = breaking change while pre-1.0, consistent with this repo's own
|
|
76
|
-
versioning history).
|
|
77
|
-
- Check the optional-provider-dependency rule from this repo's
|
|
78
|
-
`CLAUDE.md`: provider SDK `require`s (`openai`, `anthropic`,
|
|
79
|
-
`google-generativeai`, etc.) must stay scoped to the file that uses
|
|
80
|
-
them and lazy-loaded, never added to `lib/coolhand.rb` or the gemspec
|
|
81
|
-
as a hard dependency.
|
|
82
|
-
- **Security**: injection risks, unsafe deserialization, secrets or
|
|
83
|
-
credentials logged or committed, unvalidated input crossing a trust
|
|
84
|
-
boundary, and anything touching how API keys/tokens are handled,
|
|
85
|
-
stored, or transmitted.
|
|
86
|
-
|
|
87
|
-
## Post-loop verification
|
|
88
|
-
|
|
89
|
-
Once the loop converges (or stops early per the rules above), run the
|
|
90
|
-
project's lint/test command and include the result in the final summary.
|
|
91
|
-
For this repo that's `bundle exec rake` (runs `rspec` then `rubocop`, per
|
|
92
|
-
the `Rakefile`). If a fix round changed something outside this repo's
|
|
93
|
-
usual toolchain, discover the right command instead of assuming.
|
|
94
|
-
|
|
95
|
-
## Rationalizations to resist
|
|
96
|
-
|
|
97
|
-
- *"The first round already looked clean, I don't need a confirming
|
|
98
|
-
round."* A fix round can introduce its own regression. Always re-review
|
|
99
|
-
after applying fixes before declaring convergence.
|
|
100
|
-
- *"Rubocop passed, so the review is done."* Lint passing is not the same
|
|
101
|
-
as the review being clean — lint doesn't check the criteria above
|
|
102
|
-
(interface breakage, changelog/version discipline, security). Run both.
|
|
103
|
-
- *"This finding keeps coming back, I'll just keep re-running --fix and
|
|
104
|
-
it'll eventually take."* If the same non-empty finding set repeats
|
|
105
|
-
across two rounds, `--fix` isn't going to resolve it. Stop and surface
|
|
106
|
-
it — looping past that point just burns rounds for no gain.
|
|
107
|
-
|
|
108
|
-
## Safety
|
|
109
|
-
|
|
110
|
-
- Never force-push or amend existing commits as part of this loop.
|
|
111
|
-
- The skill only edits the working tree; committing and pushing stays with
|
|
112
|
-
the user.
|