spurline-deploy 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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +161 -0
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Spurline
5
+ module Tools
6
+ # Executes tool calls with permission checking, confirmation, and result wrapping.
7
+ # Tool results always enter the pipeline as Content objects with trust: :external.
8
+ class Runner
9
+ attr_reader :registry
10
+
11
+ def initialize(registry:, guardrails: {}, permissions: {}, secret_resolver: nil, idempotency_configs: {})
12
+ @registry = registry
13
+ @guardrails = guardrails
14
+ @permissions = permissions
15
+ @secret_resolver = secret_resolver
16
+ @idempotency_configs = normalize_idempotency_configs(idempotency_configs)
17
+ end
18
+
19
+ # ASYNC-READY: scheduler param is the async entry point
20
+ def execute(
21
+ tool_call,
22
+ session:,
23
+ scheduler: Spurline::Adapters::Scheduler::Sync.new,
24
+ scope: nil,
25
+ idempotency_ledger: nil,
26
+ &confirmation_handler
27
+ )
28
+ tool_name = tool_call[:name].to_s
29
+ registered_tool = @registry.fetch(tool_name)
30
+ tool_class = registered_tool.is_a?(Class) ? registered_tool : registered_tool.class
31
+ tool = registered_tool.is_a?(Class) ? registered_tool.new : registered_tool
32
+
33
+ permission_check!(tool_name, session)
34
+ confirmation_check!(tool_name, tool_class, tool_call, &confirmation_handler)
35
+
36
+ started_at = Time.now
37
+ args = symbolize_keys(tool_call[:arguments])
38
+ if tool_class.respond_to?(:validate_arguments!)
39
+ tool_class.validate_arguments!(args)
40
+ end
41
+ scoped_tool = tool_class.respond_to?(:scoped?) && tool_class.scoped?
42
+ if scoped_tool && scope.nil?
43
+ raise Spurline::ScopeViolationError,
44
+ "Tool '#{tool_name}' is scoped and requires a scope, but none was provided."
45
+ end
46
+ scope_id = scoped_tool && scope.respond_to?(:id) ? scope.id.to_s : nil
47
+
48
+ idempotency = build_idempotency_context(
49
+ tool_name: tool_name,
50
+ tool_class: tool_class,
51
+ args: args,
52
+ scoped_tool: scoped_tool,
53
+ scope_id: scope_id,
54
+ idempotency_ledger: idempotency_ledger || (session.metadata[:idempotency_ledger] ||= {})
55
+ )
56
+
57
+ was_cached = false
58
+ cache_age_ms = nil
59
+ raw_result = nil
60
+
61
+ if idempotency[:enabled]
62
+ idempotency[:ledger].cached?(idempotency[:key], ttl: idempotency[:ttl])
63
+ if idempotency[:ledger].conflict?(idempotency[:key], idempotency[:args_hash])
64
+ raise Spurline::IdempotencyKeyConflictError,
65
+ "Tool '#{tool_name}' generated idempotency key '#{idempotency[:key]}' " \
66
+ "for different arguments in the same session."
67
+ end
68
+
69
+ cached_result = idempotency[:ledger].fetch(idempotency[:key], ttl: idempotency[:ttl])
70
+ unless cached_result.nil?
71
+ raw_result = cached_result
72
+ was_cached = true
73
+ cache_age_ms = idempotency[:ledger].cache_age_ms(idempotency[:key])
74
+ end
75
+ end
76
+
77
+ unless was_cached
78
+ args = inject_secrets(tool_class, args)
79
+ args = inject_scope(args, scope: scope) if scoped_tool
80
+ raw_result = scheduler.run { tool.call(**args) }
81
+ end
82
+
83
+ serialized_result = was_cached ? raw_result.to_s : serialize_result(raw_result)
84
+ if idempotency[:enabled] && !was_cached
85
+ idempotency[:ledger].store!(
86
+ idempotency[:key],
87
+ result: serialized_result,
88
+ args_hash: idempotency[:args_hash],
89
+ ttl: idempotency[:ttl]
90
+ )
91
+ end
92
+
93
+ duration_ms = ((Time.now - started_at) * 1000).round
94
+ arguments_for_audit = args.dup
95
+ arguments_for_audit.delete(:_scope)
96
+ arguments_for_audit[:_scope_id] = scope_id if scoped_tool && scope_id
97
+ filtered_arguments = Audit::SecretFilter.filter(
98
+ arguments_for_audit,
99
+ tool_name: tool_name,
100
+ registry: @registry
101
+ )
102
+
103
+ session.current_turn&.record_tool_call(
104
+ name: tool_name,
105
+ arguments: filtered_arguments,
106
+ result: raw_result,
107
+ duration_ms: duration_ms,
108
+ scope_id: scope_id,
109
+ idempotency_key: idempotency[:enabled] ? idempotency[:key] : nil,
110
+ was_cached: idempotency[:enabled] ? was_cached : nil,
111
+ cache_age_ms: cache_age_ms
112
+ )
113
+
114
+ Security::Gates::ToolResult.wrap(
115
+ serialized_result,
116
+ tool_name: tool_name
117
+ )
118
+ rescue ArgumentError => e
119
+ raise unless missing_keyword_argument_error?(e)
120
+
121
+ raise Spurline::ConfigurationError.new(
122
+ "Tool '#{tool_name}' received invalid arguments #{tool_call[:arguments].inspect}: #{e.message}"
123
+ ), cause: nil
124
+ rescue Spurline::ConfigurationError => e
125
+ raise Spurline::ConfigurationError.new(
126
+ "Invalid tool call for '#{tool_name}' with arguments #{tool_call[:arguments].inspect}: #{e.message}"
127
+ ), cause: nil
128
+ end
129
+
130
+ private
131
+
132
+ def permission_check!(tool_name, session)
133
+ tool_perms = @permissions[tool_name.to_sym] || @permissions[tool_name.to_s]
134
+ return unless tool_perms
135
+
136
+ if tool_perms[:denied]
137
+ raise Spurline::PermissionDeniedError,
138
+ "Tool '#{tool_name}' is denied by the permission configuration. " \
139
+ "Check config/permissions.yml or the agent's permission settings."
140
+ end
141
+
142
+ if tool_perms[:allowed_users] && session.user
143
+ unless tool_perms[:allowed_users].include?(session.user)
144
+ raise Spurline::PermissionDeniedError,
145
+ "Tool '#{tool_name}' is not permitted for user '#{session.user}'. " \
146
+ "Allowed users: #{tool_perms[:allowed_users].join(", ")}."
147
+ end
148
+ end
149
+ end
150
+
151
+ def confirmation_check!(tool_name, tool_class, tool_call, &confirmation_handler)
152
+ needs_confirmation = tool_class.respond_to?(:requires_confirmation?) && tool_class.requires_confirmation?
153
+
154
+ # Also check permissions config
155
+ tool_perms = @permissions[tool_name.to_sym] || @permissions[tool_name.to_s]
156
+ needs_confirmation ||= tool_perms&.dig(:requires_confirmation)
157
+
158
+ return unless needs_confirmation
159
+ return unless confirmation_handler
160
+
161
+ confirmed = confirmation_handler.call(
162
+ tool_name: tool_name,
163
+ arguments: tool_call[:arguments]
164
+ )
165
+
166
+ unless confirmed
167
+ raise Spurline::PermissionDeniedError,
168
+ "Tool '#{tool_name}' requires confirmation, but confirmation was denied. " \
169
+ "The user or operator declined to execute this tool."
170
+ end
171
+ end
172
+
173
+ def symbolize_keys(hash)
174
+ return {} unless hash
175
+
176
+ hash.transform_keys(&:to_sym)
177
+ end
178
+
179
+ def inject_secrets(tool_class, args)
180
+ return args unless @secret_resolver
181
+ return args unless tool_class.respond_to?(:declared_secrets)
182
+
183
+ secrets = tool_class.declared_secrets
184
+ return args if secrets.empty?
185
+
186
+ injected = args.dup
187
+ secrets.each do |secret_def|
188
+ name = secret_def[:name]
189
+ next if injected.key?(name)
190
+
191
+ injected[name] = @secret_resolver.resolve!(name)
192
+ end
193
+ injected
194
+ end
195
+
196
+ def inject_scope(args, scope:)
197
+ args.merge(_scope: scope)
198
+ end
199
+
200
+ def build_idempotency_context(tool_name:, tool_class:, args:, scoped_tool:, scope_id:, idempotency_ledger:)
201
+ dsl_options = @idempotency_configs[tool_name.to_sym] || {}
202
+ config = Spurline::Tools::Idempotency::Config.from_dsl(dsl_options, tool_class: tool_class)
203
+ return { enabled: false } unless config.enabled?
204
+
205
+ ledger = if idempotency_ledger.is_a?(Spurline::Tools::Idempotency::Ledger)
206
+ idempotency_ledger
207
+ else
208
+ Spurline::Tools::Idempotency::Ledger.new(idempotency_ledger || {})
209
+ end
210
+
211
+ args_for_hash = args.dup
212
+ args_for_hash[:_scope_id] = scope_id if scoped_tool && scope_id
213
+
214
+ key_tool_name = scoped_tool && scope_id ? "#{tool_name}@#{scope_id}" : tool_name
215
+ key = Spurline::Tools::Idempotency::KeyComputer.compute(
216
+ tool_name: key_tool_name,
217
+ args: args_for_hash,
218
+ key_params: config.key_params,
219
+ key_fn: config.key_fn
220
+ )
221
+
222
+ {
223
+ enabled: true,
224
+ ttl: config.ttl,
225
+ key: key,
226
+ args_hash: Spurline::Tools::Idempotency::KeyComputer.canonical_hash(args_for_hash),
227
+ ledger: ledger,
228
+ }
229
+ end
230
+
231
+ def serialize_result(raw_result)
232
+ return raw_result if raw_result.is_a?(String)
233
+
234
+ JSON.generate(raw_result)
235
+ rescue JSON::GeneratorError, TypeError
236
+ raw_result.to_s
237
+ end
238
+
239
+ def missing_keyword_argument_error?(error)
240
+ message = error.message.to_s
241
+ message.include?("missing keyword") || message.include?("unknown keyword")
242
+ end
243
+
244
+ def normalize_idempotency_configs(raw)
245
+ return {} unless raw.is_a?(Hash)
246
+
247
+ raw.each_with_object({}) do |(tool_name, config), normalized|
248
+ next unless config.is_a?(Hash)
249
+
250
+ normalized[tool_name.to_sym] = symbolize_keys(config)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Tools
5
+ class Scope
6
+ TYPES = %i[branch pr repo review_app custom].freeze
7
+
8
+ CONSTRAINT_KEYS = {
9
+ path: :paths,
10
+ branch: :branches,
11
+ repo: :repos,
12
+ }.freeze
13
+
14
+ attr_reader :id, :type, :constraints, :metadata
15
+
16
+ # Creates a new scope.
17
+ #
18
+ # @param id [String] Scope identifier (e.g., branch name, PR number)
19
+ # @param type [Symbol] One of TYPES
20
+ # @param constraints [Hash] Resource constraints
21
+ # - paths: [Array<String>] Glob patterns for file paths (e.g., "src/**")
22
+ # - branches: [Array<String>] Glob patterns for branch names (e.g., "feature-*")
23
+ # - repos: [Array<String>] Exact repo identifiers (e.g., "org/repo")
24
+ # @param metadata [Hash] Arbitrary metadata
25
+ def initialize(id:, type: :custom, constraints: {}, metadata: {})
26
+ type = type.to_sym
27
+ validate_type!(type)
28
+
29
+ @id = id.to_s
30
+ @type = type
31
+ @constraints = normalize_constraints(constraints)
32
+ @metadata = deep_copy(metadata || {})
33
+
34
+ deep_freeze(@constraints)
35
+ deep_freeze(@metadata)
36
+ freeze
37
+ end
38
+
39
+ # Checks if a resource is within scope constraints.
40
+ #
41
+ # @param resource [String] Resource identifier to check
42
+ # @param type [Symbol, nil] Resource type (:path, :branch, :repo) to narrow which constraints apply
43
+ # @return [Boolean]
44
+ #
45
+ # Matching rules:
46
+ # - Empty constraints → everything permitted (open scope)
47
+ # - Glob patterns matched via File.fnmatch (supports *, **, ?, [])
48
+ # - Repos matched via exact string match or prefix match (org/repo)
49
+ # - When type is specified, only that constraint category is checked
50
+ # - When type is nil, all constraint categories are checked (any match = permit)
51
+ def permits?(resource, type: nil)
52
+ return true if constraints.empty?
53
+
54
+ resource = resource.to_s
55
+
56
+ if type
57
+ category = CONSTRAINT_KEYS.fetch(type.to_sym) do
58
+ raise Spurline::ConfigurationError,
59
+ "Invalid scope resource type: #{type.inspect}. " \
60
+ "Must be one of: #{CONSTRAINT_KEYS.keys.map(&:inspect).join(', ')}."
61
+ end
62
+
63
+ return true unless constraints.key?(category)
64
+
65
+ patterns = constraints.fetch(category)
66
+ return false if patterns.empty?
67
+
68
+ return matches_constraint?(resource, patterns, type.to_sym)
69
+ end
70
+
71
+ constrained_categories = constraints.keys
72
+ return true if constrained_categories.empty?
73
+
74
+ constrained_categories.any? do |category|
75
+ patterns = constraints.fetch(category)
76
+ next false if patterns.empty?
77
+
78
+ match_type = CONSTRAINT_KEYS.key(category)
79
+ matches_constraint?(resource, patterns, match_type)
80
+ end
81
+ end
82
+
83
+ # Raises ScopeViolationError if resource is out of bounds.
84
+ #
85
+ # @param resource [String] Resource to check
86
+ # @param type [Symbol, nil] Resource type
87
+ # @raise [ScopeViolationError] with actionable message including scope id and resource
88
+ def enforce!(resource, type: nil)
89
+ return nil if permits?(resource, type: type)
90
+
91
+ raise_scope_violation!(resource, type)
92
+ end
93
+
94
+ # Returns a new scope with additional constraints applied (intersection).
95
+ # The result is always equal or narrower than self.
96
+ #
97
+ # @param additional_constraints [Hash] Constraints to intersect with current
98
+ # @return [Scope] New scope (narrower or equal)
99
+ #
100
+ # Intersection rules:
101
+ # - If both have a category, result is the intersection of patterns
102
+ # - If only parent has a category, it carries through
103
+ # - If only child has a category, it's added
104
+ def narrow(additional_constraints)
105
+ additional = normalize_constraints(additional_constraints || {})
106
+ merged = {}
107
+
108
+ (constraints.keys | additional.keys).each do |category|
109
+ parent_patterns = constraints[category]
110
+ child_patterns = additional[category]
111
+
112
+ if parent_patterns && child_patterns
113
+ match_type = CONSTRAINT_KEYS.key(category)
114
+ merged[category] = intersect_patterns(parent_patterns, child_patterns, match_type)
115
+ elsif parent_patterns
116
+ merged[category] = deep_copy(parent_patterns)
117
+ elsif child_patterns
118
+ merged[category] = deep_copy(child_patterns)
119
+ end
120
+ end
121
+
122
+ self.class.new(id: id, type: type, constraints: merged, metadata: metadata)
123
+ end
124
+
125
+ # Validates that this scope is a subset of (equal or narrower than) another.
126
+ #
127
+ # @param other [Scope] Parent scope to compare against
128
+ # @return [Boolean]
129
+ #
130
+ # A scope is a subset if for every constraint category:
131
+ # - Parent has no constraint on that category (child is free), OR
132
+ # - Every child pattern matches at least one parent pattern
133
+ def subset_of?(other)
134
+ return false unless other.is_a?(self.class)
135
+
136
+ CONSTRAINT_KEYS.values.all? do |category|
137
+ parent_has_category = other.constraints.key?(category)
138
+ child_has_category = constraints.key?(category)
139
+
140
+ next true unless parent_has_category
141
+ next false unless child_has_category
142
+
143
+ child_patterns = constraints.fetch(category)
144
+ parent_patterns = other.constraints.fetch(category)
145
+
146
+ child_patterns.all? do |pattern|
147
+ match_type = CONSTRAINT_KEYS.key(category)
148
+ matches_constraint?(pattern, parent_patterns, match_type)
149
+ end
150
+ end
151
+ end
152
+
153
+ # Serialization
154
+ def to_h
155
+ {
156
+ id: id,
157
+ type: type,
158
+ constraints: deep_copy(constraints),
159
+ metadata: deep_copy(metadata),
160
+ }
161
+ end
162
+
163
+ def self.from_h(data)
164
+ hash = deep_symbolize(data || {})
165
+
166
+ new(
167
+ id: hash.fetch(:id),
168
+ type: hash.fetch(:type, :custom),
169
+ constraints: hash.fetch(:constraints, {}),
170
+ metadata: hash.fetch(:metadata, {})
171
+ )
172
+ end
173
+
174
+ private
175
+
176
+ def validate_type!(type)
177
+ return if TYPES.include?(type)
178
+
179
+ raise Spurline::ConfigurationError,
180
+ "Invalid scope type: #{type.inspect}. " \
181
+ "Must be one of: #{TYPES.map(&:inspect).join(', ')}."
182
+ end
183
+
184
+ def normalize_constraints(raw)
185
+ source = self.class.send(:deep_symbolize, raw || {})
186
+
187
+ source.each_with_object({}) do |(key, value), normalized|
188
+ unless CONSTRAINT_KEYS.value?(key)
189
+ raise Spurline::ConfigurationError,
190
+ "Invalid scope constraint category: #{key.inspect}. " \
191
+ "Must be one of: #{CONSTRAINT_KEYS.values.map(&:inspect).join(', ')}."
192
+ end
193
+
194
+ normalized[key] = Array(value).compact.map(&:to_s)
195
+ end
196
+ end
197
+
198
+ def deep_copy(obj)
199
+ case obj
200
+ when Hash
201
+ obj.each_with_object({}) do |(key, value), copy|
202
+ copy[deep_copy(key)] = deep_copy(value)
203
+ end
204
+ when Array
205
+ obj.map { |value| deep_copy(value) }
206
+ when String
207
+ obj.dup
208
+ else
209
+ obj
210
+ end
211
+ end
212
+
213
+ def deep_freeze(obj)
214
+ case obj
215
+ when Hash
216
+ obj.each do |key, value|
217
+ deep_freeze(key)
218
+ deep_freeze(value)
219
+ end
220
+ when Array
221
+ obj.each { |value| deep_freeze(value) }
222
+ end
223
+
224
+ obj.freeze
225
+ end
226
+
227
+ def matches_constraint?(resource, patterns, match_type)
228
+ patterns.any? do |pattern|
229
+ case match_type
230
+ when :repo
231
+ resource == pattern || resource.start_with?("#{pattern}/")
232
+ when :path, :branch
233
+ glob_match?(pattern, resource)
234
+ else
235
+ false
236
+ end
237
+ end
238
+ end
239
+
240
+ def glob_match?(pattern, value)
241
+ if pattern.include?("**")
242
+ match_segments_with_double_star?(pattern.split("/"), value.split("/"))
243
+ else
244
+ File.fnmatch(pattern, value, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
245
+ end
246
+ end
247
+
248
+ def match_segments_with_double_star?(pattern_segments, value_segments)
249
+ if pattern_segments.empty?
250
+ return value_segments.empty?
251
+ end
252
+
253
+ current = pattern_segments.first
254
+
255
+ if current == "**"
256
+ return true if pattern_segments.length == 1
257
+
258
+ tail = pattern_segments.drop(1)
259
+ (0..value_segments.length).any? do |offset|
260
+ match_segments_with_double_star?(tail, value_segments.drop(offset))
261
+ end
262
+ else
263
+ return false if value_segments.empty?
264
+ return false unless File.fnmatch(current, value_segments.first, File::FNM_EXTGLOB | File::FNM_DOTMATCH)
265
+
266
+ match_segments_with_double_star?(pattern_segments.drop(1), value_segments.drop(1))
267
+ end
268
+ end
269
+
270
+ def intersect_patterns(parent_patterns, child_patterns, match_type)
271
+ intersection = []
272
+
273
+ child_patterns.each do |child_pattern|
274
+ intersection << child_pattern if matches_constraint?(child_pattern, parent_patterns, match_type)
275
+ end
276
+
277
+ parent_patterns.each do |parent_pattern|
278
+ intersection << parent_pattern if matches_constraint?(parent_pattern, child_patterns, match_type)
279
+ end
280
+
281
+ intersection.uniq
282
+ end
283
+
284
+ def raise_scope_violation!(resource, type)
285
+ type_suffix = type ? " (resource type: #{type})" : ""
286
+
287
+ raise Spurline::ScopeViolationError,
288
+ "Scope '#{id}' (#{self.type}) does not permit resource '#{resource}'#{type_suffix}."
289
+ end
290
+
291
+ class << self
292
+ private
293
+
294
+ def deep_symbolize(value)
295
+ case value
296
+ when Hash
297
+ value.each_with_object({}) do |(key, item), result|
298
+ result[key.to_sym] = deep_symbolize(item)
299
+ end
300
+ when Array
301
+ value.map { |item| deep_symbolize(item) }
302
+ else
303
+ value
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Tools
5
+ # Registry for named toolkits. Toolkits register here and are
6
+ # looked up by name when an agent declares `toolkits :name`.
7
+ #
8
+ # When a tool_registry is provided, registering a toolkit also
9
+ # registers all its owned tools into the tool registry — so they're
10
+ # available for standalone `tools :name` references too.
11
+ class ToolkitRegistry
12
+ def initialize(tool_registry: nil)
13
+ @toolkits = {}
14
+ @tool_registry = tool_registry
15
+ end
16
+
17
+ def register(name, toolkit_class)
18
+ name = name.to_sym
19
+ @toolkits[name] = toolkit_class
20
+ register_toolkit_tools!(toolkit_class) if @tool_registry
21
+ end
22
+
23
+ def fetch(name)
24
+ name = name.to_sym
25
+ @toolkits.fetch(name) do
26
+ raise Spurline::ToolkitNotFoundError,
27
+ "Toolkit :#{name} not found. Available toolkits: #{names.join(', ')}. " \
28
+ "Define a toolkit class inheriting from Spurline::Toolkit and declare " \
29
+ "`toolkit_name :#{name}`."
30
+ end
31
+ end
32
+
33
+ def registered?(name)
34
+ @toolkits.key?(name.to_sym)
35
+ end
36
+
37
+ # Returns the tool names that a toolkit expands to.
38
+ def expand(name)
39
+ fetch(name).tools
40
+ end
41
+
42
+ def all
43
+ @toolkits.dup
44
+ end
45
+
46
+ def names
47
+ @toolkits.keys
48
+ end
49
+
50
+ def clear!
51
+ @toolkits.clear
52
+ end
53
+
54
+ private
55
+
56
+ def register_toolkit_tools!(toolkit_class)
57
+ toolkit_class.tool_classes.each do |tool_name, tool_class|
58
+ @tool_registry.register(tool_name, tool_class) unless @tool_registry.registered?(tool_name)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ VERSION = "0.3.0"
5
+ end
data/lib/spurline.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require_relative "spurline/errors"
5
+
6
+ module Spurline
7
+ class << self
8
+ def configure(&block)
9
+ Configuration.configure(&block)
10
+ end
11
+
12
+ def config
13
+ Configuration.config
14
+ end
15
+
16
+ def analyze_repo(path)
17
+ Cartographer::Runner.new.analyze(repo_path: path)
18
+ end
19
+
20
+ def credentials
21
+ @credentials ||= CLI::Credentials.new(project_root: Dir.pwd).read
22
+ end
23
+
24
+ def reset_credentials!
25
+ @credentials = nil
26
+ end
27
+
28
+ def loader
29
+ @loader ||= begin
30
+ loader = Zeitwerk::Loader.for_gem
31
+ loader.inflector.inflect("dsl" => "DSL")
32
+ loader.inflector.inflect("pii_filter" => "PIIFilter")
33
+ loader.inflector.inflect("cli" => "CLI")
34
+ loader.inflector.inflect("ci_config" => "CIConfig")
35
+ loader.inflector.inflect("sqlite" => "SQLite")
36
+ loader.inflector.inflect("open_ai" => "OpenAI")
37
+ loader.inflector.inflect("github" => "GitHub")
38
+ loader.ignore("#{__dir__}/spurline/errors.rb")
39
+ loader
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Spurline.loader.setup
46
+
47
+ # Auto-discover and load spur gems from the bundle.
48
+ if defined?(Bundler)
49
+ Bundler.load.current_dependencies.each do |dep|
50
+ next unless dep.name.start_with?("spurline-") && dep.name != "spurline-core"
51
+
52
+ require dep.name
53
+ rescue LoadError
54
+ # Spur gem is in Gemfile but not loadable — skip silently.
55
+ end
56
+ end