spurline-core 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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. metadata +333 -0
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Orchestration
5
+ # Deterministic FIFO merge queue with explicit conflict handling strategies.
6
+ class MergeQueue
7
+ STRATEGIES = %i[escalate file_level union].freeze
8
+
9
+ ConflictReport = Struct.new(:task_id, :conflicting_task_id, :resource, :details, keyword_init: true)
10
+ MergeResult = Struct.new(:success, :merged_output, :conflicts, keyword_init: true) do
11
+ def success?
12
+ success
13
+ end
14
+ end
15
+
16
+ def initialize(strategy: :escalate)
17
+ @strategy = strategy.to_sym
18
+ validate_strategy!(@strategy)
19
+ @queue = []
20
+ end
21
+
22
+ def enqueue(task_id:, output:)
23
+ unless output.is_a?(Hash)
24
+ raise ArgumentError, "merge output must be a hash"
25
+ end
26
+
27
+ @queue << { task_id: task_id.to_s, output: deep_copy(output) }
28
+ end
29
+
30
+ def process(existing_output: {})
31
+ merged = deep_copy(existing_output)
32
+ key_sources = merged.keys.each_with_object({}) { |key, map| map[key] = nil }
33
+ conflicts = []
34
+
35
+ until @queue.empty?
36
+ entry = @queue.shift
37
+ overlaps = detect_conflicts(merged, entry)
38
+
39
+ case @strategy
40
+ when :escalate
41
+ if overlaps.any?
42
+ conflicts.concat(build_conflict_reports(entry, overlaps, key_sources, strategy: :escalate))
43
+ next
44
+ end
45
+
46
+ merge_entry!(merged, key_sources, entry)
47
+ when :file_level
48
+ conflicts.concat(build_conflict_reports(entry, overlaps, key_sources, strategy: :file_level))
49
+ overlapping_keys = overlaps.map { |item| item[:resource] }
50
+
51
+ entry[:output].each do |key, value|
52
+ next if overlapping_keys.include?(key)
53
+
54
+ merged[key] = deep_copy(value)
55
+ key_sources[key] = entry[:task_id]
56
+ end
57
+ when :union
58
+ conflicts.concat(build_conflict_reports(entry, overlaps, key_sources, strategy: :union))
59
+ merge_entry!(merged, key_sources, entry)
60
+ end
61
+ end
62
+
63
+ success = @strategy == :escalate ? conflicts.empty? : true
64
+ MergeResult.new(success: success, merged_output: merged, conflicts: conflicts)
65
+ end
66
+
67
+ def size
68
+ @queue.size
69
+ end
70
+
71
+ def empty?
72
+ @queue.empty?
73
+ end
74
+
75
+ private
76
+
77
+ # Conflict detection: hash-key overlap with different values.
78
+ def detect_conflicts(existing, entry)
79
+ entry[:output].each_with_object([]) do |(key, value), conflicts|
80
+ next unless existing.key?(key)
81
+ next if existing[key] == value
82
+
83
+ conflicts << {
84
+ resource: key,
85
+ existing_value: deep_copy(existing[key]),
86
+ incoming_value: deep_copy(value),
87
+ }
88
+ end
89
+ end
90
+
91
+ def validate_strategy!(strategy)
92
+ return if STRATEGIES.include?(strategy)
93
+
94
+ raise Spurline::ConfigurationError, "invalid merge strategy: #{strategy.inspect}"
95
+ end
96
+
97
+ def merge_entry!(merged, key_sources, entry)
98
+ entry[:output].each do |key, value|
99
+ merged[key] = deep_copy(value)
100
+ key_sources[key] = entry[:task_id]
101
+ end
102
+ end
103
+
104
+ def build_conflict_reports(entry, overlaps, key_sources, strategy:)
105
+ overlaps.map do |overlap|
106
+ ConflictReport.new(
107
+ task_id: entry[:task_id],
108
+ conflicting_task_id: key_sources[overlap[:resource]],
109
+ resource: overlap[:resource],
110
+ details: {
111
+ strategy: strategy,
112
+ existing_value: overlap[:existing_value],
113
+ incoming_value: overlap[:incoming_value],
114
+ }
115
+ )
116
+ end
117
+ end
118
+
119
+ def deep_copy(value)
120
+ case value
121
+ when Hash
122
+ value.each_with_object({}) do |(key, item), copy|
123
+ copy[key] = deep_copy(item)
124
+ end
125
+ when Array
126
+ value.map { |item| deep_copy(item) }
127
+ else
128
+ value
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Orchestration
5
+ module PermissionIntersection
6
+ module_function
7
+
8
+ # Computes effective parent->child permissions under the setuid rule.
9
+ # Result is always <= parent when both define the same tool.
10
+ def compute(parent_permissions, child_permissions)
11
+ parent = normalize_permissions(parent_permissions)
12
+ child = normalize_permissions(child_permissions)
13
+
14
+ tool_names = (parent.keys + child.keys).uniq
15
+
16
+ tool_names.each_with_object({}) do |tool_name, result|
17
+ parent_tool = parent[tool_name]
18
+ child_tool = child[tool_name]
19
+
20
+ result[tool_name] = if parent_tool && child_tool
21
+ intersect_tool(parent_tool, child_tool)
22
+ elsif parent_tool
23
+ deep_copy(parent_tool)
24
+ else
25
+ deep_copy(child_tool)
26
+ end
27
+ end
28
+ end
29
+
30
+ # Validates that child permissions do not exceed parent permissions.
31
+ # Raises PrivilegeEscalationError if a child broadens access.
32
+ def validate_no_escalation!(parent, child)
33
+ normalized_parent = normalize_permissions(parent)
34
+ normalized_child = normalize_permissions(child)
35
+
36
+ normalized_child.each do |tool_name, child_tool|
37
+ parent_tool = normalized_parent[tool_name]
38
+ next unless parent_tool
39
+
40
+ validate_denied!(tool_name, parent_tool, child_tool)
41
+ validate_requires_confirmation!(tool_name, parent_tool, child_tool)
42
+ validate_allowed_users!(tool_name, parent_tool, child_tool)
43
+ end
44
+
45
+ true
46
+ end
47
+
48
+ def intersect_tool(parent_tool, child_tool)
49
+ denied = truthy?(parent_tool[:denied]) || truthy?(child_tool[:denied])
50
+ requires_confirmation = truthy?(parent_tool[:requires_confirmation]) ||
51
+ truthy?(child_tool[:requires_confirmation])
52
+
53
+ parent_users = normalize_users(parent_tool[:allowed_users])
54
+ child_users = normalize_users(child_tool[:allowed_users])
55
+
56
+ allowed_users = if parent_users && child_users
57
+ parent_users & child_users
58
+ elsif parent_users
59
+ parent_users
60
+ else
61
+ child_users
62
+ end
63
+
64
+ result = {
65
+ denied: denied,
66
+ requires_confirmation: requires_confirmation,
67
+ }
68
+ result[:allowed_users] = allowed_users if allowed_users
69
+ result
70
+ end
71
+ private_class_method :intersect_tool
72
+
73
+ def validate_denied!(tool_name, parent_tool, child_tool)
74
+ return unless truthy?(parent_tool[:denied]) && !truthy?(child_tool[:denied])
75
+
76
+ raise Spurline::PrivilegeEscalationError, "child tool #{tool_name} removes denied=true"
77
+ end
78
+ private_class_method :validate_denied!
79
+
80
+ def validate_requires_confirmation!(tool_name, parent_tool, child_tool)
81
+ return unless truthy?(parent_tool[:requires_confirmation]) && !truthy?(child_tool[:requires_confirmation])
82
+
83
+ raise Spurline::PrivilegeEscalationError, "child tool #{tool_name} removes requires_confirmation=true"
84
+ end
85
+ private_class_method :validate_requires_confirmation!
86
+
87
+ def validate_allowed_users!(tool_name, parent_tool, child_tool)
88
+ parent_users = normalize_users(parent_tool[:allowed_users])
89
+ child_users = normalize_users(child_tool[:allowed_users])
90
+
91
+ return if parent_users.nil?
92
+
93
+ if child_users.nil?
94
+ raise Spurline::PrivilegeEscalationError,
95
+ "child tool #{tool_name} omits allowed_users while parent restricts it"
96
+ end
97
+
98
+ extra_users = child_users - parent_users
99
+ return if extra_users.empty?
100
+
101
+ raise Spurline::PrivilegeEscalationError,
102
+ "child tool #{tool_name} adds users not allowed by parent: #{extra_users.join(", ")}"
103
+ end
104
+ private_class_method :validate_allowed_users!
105
+
106
+ def normalize_permissions(permissions)
107
+ raw = permissions || {}
108
+
109
+ raw.each_with_object({}) do |(tool_name, config), normalized|
110
+ normalized[tool_name.to_sym] = normalize_tool_config(config)
111
+ end
112
+ end
113
+ private_class_method :normalize_permissions
114
+
115
+ def normalize_tool_config(config)
116
+ return {} unless config.is_a?(Hash)
117
+
118
+ config.each_with_object({}) do |(key, value), normalized|
119
+ normalized[key.to_sym] = key.to_sym == :allowed_users ? normalize_users(value) : value
120
+ end
121
+ end
122
+ private_class_method :normalize_tool_config
123
+
124
+ def normalize_users(users)
125
+ return nil if users.nil?
126
+
127
+ Array(users).map(&:to_s).uniq
128
+ end
129
+ private_class_method :normalize_users
130
+
131
+ def truthy?(value)
132
+ value == true
133
+ end
134
+ private_class_method :truthy?
135
+
136
+ def deep_copy(value)
137
+ case value
138
+ when Hash
139
+ value.each_with_object({}) do |(key, item), copy|
140
+ copy[key] = deep_copy(item)
141
+ end
142
+ when Array
143
+ value.map { |item| deep_copy(item) }
144
+ else
145
+ value
146
+ end
147
+ end
148
+ private_class_method :deep_copy
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Spurline
6
+ module Orchestration
7
+ # Immutable work unit for worker execution.
8
+ class TaskEnvelope
9
+ CURRENT_VERSION = "1.0"
10
+
11
+ attr_reader :task_id, :version, :instruction, :input_files,
12
+ :constraints, :acceptance_criteria, :output_spec,
13
+ :scoped_context, :parent_session_id,
14
+ :max_turns, :max_tool_calls, :metadata
15
+
16
+ # @param instruction [String] Natural language task description (required)
17
+ # @param acceptance_criteria [Array<String>] What the output must contain (required)
18
+ # @param task_id [String] UUID (auto-generated)
19
+ # @param version [String] Schema version
20
+ # @param input_files [Array<Hash>] Files the worker needs { path:, content: }
21
+ # @param constraints [Hash] Behavioral limits { no_modify: [...], read_only: true, ... }
22
+ # @param output_spec [Hash] Expected output format { type: :patch|:file|:answer, ... }
23
+ # @param scoped_context [Object,nil] Optional execution scope (M2.3)
24
+ # @param parent_session_id [String,nil] For audit correlation
25
+ # @param max_turns [Integer] Safety limit (default 10)
26
+ # @param max_tool_calls [Integer] Safety limit (default 20)
27
+ # @param metadata [Hash] Arbitrary extra data
28
+ def initialize(
29
+ instruction:,
30
+ acceptance_criteria:,
31
+ task_id: SecureRandom.uuid,
32
+ version: CURRENT_VERSION,
33
+ input_files: [],
34
+ constraints: {},
35
+ output_spec: {},
36
+ scoped_context: nil,
37
+ parent_session_id: nil,
38
+ max_turns: 10,
39
+ max_tool_calls: 20,
40
+ metadata: {}
41
+ )
42
+ validate_instruction!(instruction)
43
+ validate_acceptance_criteria!(acceptance_criteria)
44
+ validate_limit!(max_turns, name: "max_turns")
45
+ validate_limit!(max_tool_calls, name: "max_tool_calls")
46
+
47
+ @task_id = task_id.to_s
48
+ @version = version.to_s
49
+ @instruction = instruction.to_s
50
+ @input_files = deep_copy(input_files || [])
51
+ @constraints = deep_copy(constraints || {})
52
+ @acceptance_criteria = acceptance_criteria.map(&:to_s)
53
+ @output_spec = deep_copy(output_spec || {})
54
+ @scoped_context = normalize_scoped_context(scoped_context)
55
+ @parent_session_id = parent_session_id&.to_s
56
+ @max_turns = max_turns
57
+ @max_tool_calls = max_tool_calls
58
+ @metadata = deep_copy(metadata || {})
59
+
60
+ deep_freeze(@input_files)
61
+ deep_freeze(@constraints)
62
+ deep_freeze(@acceptance_criteria)
63
+ deep_freeze(@output_spec)
64
+ deep_freeze(@metadata)
65
+ if @scoped_context.is_a?(Hash) || @scoped_context.is_a?(Array)
66
+ deep_freeze(@scoped_context)
67
+ elsif @scoped_context.respond_to?(:freeze)
68
+ @scoped_context.freeze
69
+ end
70
+ freeze
71
+ end
72
+
73
+ def to_h
74
+ {
75
+ task_id: task_id,
76
+ version: version,
77
+ instruction: instruction,
78
+ input_files: deep_copy(input_files),
79
+ constraints: deep_copy(constraints),
80
+ acceptance_criteria: deep_copy(acceptance_criteria),
81
+ output_spec: deep_copy(output_spec),
82
+ scoped_context: serialize_scoped_context(scoped_context),
83
+ parent_session_id: parent_session_id,
84
+ max_turns: max_turns,
85
+ max_tool_calls: max_tool_calls,
86
+ metadata: deep_copy(metadata),
87
+ }
88
+ end
89
+
90
+ def self.from_h(data)
91
+ hash = deep_symbolize(data || {})
92
+ new(
93
+ task_id: hash[:task_id] || SecureRandom.uuid,
94
+ version: hash[:version] || CURRENT_VERSION,
95
+ instruction: hash.fetch(:instruction),
96
+ input_files: hash[:input_files] || [],
97
+ constraints: hash[:constraints] || {},
98
+ acceptance_criteria: hash.fetch(:acceptance_criteria),
99
+ output_spec: hash[:output_spec] || {},
100
+ scoped_context: hash[:scoped_context],
101
+ parent_session_id: hash[:parent_session_id],
102
+ max_turns: hash[:max_turns] || 10,
103
+ max_tool_calls: hash[:max_tool_calls] || 20,
104
+ metadata: hash[:metadata] || {}
105
+ )
106
+ end
107
+
108
+ private
109
+
110
+ def validate_instruction!(value)
111
+ return if value.to_s.strip != ""
112
+
113
+ raise Spurline::TaskEnvelopeError, "instruction is required"
114
+ end
115
+
116
+ def validate_acceptance_criteria!(value)
117
+ unless value.is_a?(Array) && !value.empty?
118
+ raise Spurline::TaskEnvelopeError, "acceptance_criteria must be a non-empty array"
119
+ end
120
+
121
+ if value.any? { |criterion| criterion.to_s.strip.empty? }
122
+ raise Spurline::TaskEnvelopeError, "acceptance_criteria entries must be non-empty"
123
+ end
124
+ end
125
+
126
+ def validate_limit!(value, name:)
127
+ unless value.is_a?(Integer) && value.positive?
128
+ raise Spurline::TaskEnvelopeError, "#{name} must be a positive integer"
129
+ end
130
+ end
131
+
132
+ def normalize_scoped_context(value)
133
+ return nil if value.nil?
134
+
135
+ if value.is_a?(Hash) || value.is_a?(Array)
136
+ deep_copy(value)
137
+ elsif value.respond_to?(:to_h)
138
+ deep_copy(value.to_h)
139
+ else
140
+ value
141
+ end
142
+ end
143
+
144
+ def serialize_scoped_context(value)
145
+ return nil if value.nil?
146
+
147
+ if value.is_a?(Hash) || value.is_a?(Array)
148
+ deep_copy(value)
149
+ elsif value.respond_to?(:to_h)
150
+ deep_copy(value.to_h)
151
+ else
152
+ value
153
+ end
154
+ end
155
+
156
+ def deep_copy(value)
157
+ case value
158
+ when Hash
159
+ value.each_with_object({}) do |(key, item), copy|
160
+ copy[key] = deep_copy(item)
161
+ end
162
+ when Array
163
+ value.map { |item| deep_copy(item) }
164
+ else
165
+ value
166
+ end
167
+ end
168
+
169
+ def deep_freeze(value)
170
+ case value
171
+ when Hash
172
+ value.each do |key, item|
173
+ deep_freeze(key)
174
+ deep_freeze(item)
175
+ end
176
+ when Array
177
+ value.each { |item| deep_freeze(item) }
178
+ end
179
+
180
+ value.freeze
181
+ end
182
+
183
+ class << self
184
+ private
185
+
186
+ def deep_symbolize(value)
187
+ case value
188
+ when Hash
189
+ value.each_with_object({}) do |(key, item), result|
190
+ result[key.to_sym] = deep_symbolize(item)
191
+ end
192
+ when Array
193
+ value.map { |item| deep_symbolize(item) }
194
+ else
195
+ value
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Persona
5
+ # A compiled persona. Holds the system prompt as a Content object with
6
+ # trust: :system. Frozen after compilation — cannot be modified at runtime.
7
+ class Base
8
+ attr_reader :name, :content, :injection_config
9
+
10
+ def initialize(name:, system_prompt:, injection_config: {})
11
+ @name = name.to_sym
12
+ @content = Security::Gates::SystemPrompt.wrap(
13
+ system_prompt,
14
+ persona: name.to_s
15
+ )
16
+ @injection_config = injection_config.freeze
17
+ freeze
18
+ end
19
+
20
+ # Returns the system prompt as a Content object.
21
+ def render
22
+ content
23
+ end
24
+
25
+ def system_prompt_text
26
+ content.text
27
+ end
28
+
29
+ def inject_date?
30
+ injection_config.fetch(:inject_date, false)
31
+ end
32
+
33
+ def inject_user_context?
34
+ injection_config.fetch(:inject_user_context, false)
35
+ end
36
+
37
+ def inject_agent_context?
38
+ injection_config.fetch(:inject_agent_context, false)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Persona
5
+ # Per-class storage of compiled personas. Supports multiple personas
6
+ # per agent class, selectable at instantiation time.
7
+ class Registry
8
+ def initialize
9
+ @personas = {}
10
+ end
11
+
12
+ def register(name, persona)
13
+ @personas[name.to_sym] = persona
14
+ end
15
+
16
+ def fetch(name)
17
+ name = name.to_sym
18
+ @personas.fetch(name) do
19
+ raise Spurline::ConfigurationError,
20
+ "Persona '#{name}' is not defined. Available personas: " \
21
+ "#{@personas.keys.map(&:inspect).join(", ")}."
22
+ end
23
+ end
24
+
25
+ def default
26
+ fetch(:default)
27
+ rescue Spurline::ConfigurationError
28
+ nil
29
+ end
30
+
31
+ def names
32
+ @personas.keys
33
+ end
34
+
35
+ def dup_registry
36
+ new_registry = self.class.new
37
+ @personas.each { |name, persona| new_registry.register(name, persona) }
38
+ new_registry
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Secrets
5
+ class Resolver
6
+ def initialize(vault: nil, overrides: {})
7
+ @vault = vault
8
+ @overrides = overrides || {}
9
+ end
10
+
11
+ # Returns resolved value or nil.
12
+ def resolve(secret_name)
13
+ name = secret_name.to_sym
14
+
15
+ if @overrides.key?(name)
16
+ return resolve_override(@overrides[name])
17
+ end
18
+
19
+ if @vault&.key?(name)
20
+ return @vault.fetch(name)
21
+ end
22
+
23
+ cred_value = Spurline.credentials[name.to_s]
24
+ return cred_value if present?(cred_value)
25
+
26
+ env_value = ENV[name.to_s.upcase]
27
+ return env_value if present?(env_value)
28
+
29
+ nil
30
+ end
31
+
32
+ # Returns resolved value or raises SecretNotFoundError.
33
+ def resolve!(secret_name)
34
+ value = resolve(secret_name)
35
+ return value unless value.nil?
36
+
37
+ raise Spurline::SecretNotFoundError,
38
+ "Secret '#{secret_name}' is required but could not be resolved. " \
39
+ "Provide it via: agent.vault.store(:#{secret_name}, '...'), " \
40
+ "Spurline.credentials['#{secret_name}'] (spur credentials:edit), " \
41
+ "or ENV['#{secret_name.to_s.upcase}']."
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_override(override)
47
+ case override
48
+ when Proc, Method
49
+ override.call
50
+ when Symbol, String
51
+ Spurline.credentials[override.to_s]
52
+ else
53
+ override
54
+ end
55
+ end
56
+
57
+ def present?(value)
58
+ return false if value.nil?
59
+ return !value.strip.empty? if value.respond_to?(:strip)
60
+
61
+ true
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Secrets
5
+ class Vault
6
+ def initialize
7
+ @store = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def store(key, value)
12
+ @mutex.synchronize { @store[key.to_sym] = value }
13
+ end
14
+ alias []= store
15
+
16
+ def fetch(key, default = nil)
17
+ @mutex.synchronize { @store.fetch(key.to_sym, default) }
18
+ end
19
+ alias [] fetch
20
+
21
+ def key?(key)
22
+ @mutex.synchronize { @store.key?(key.to_sym) }
23
+ end
24
+
25
+ def delete(key)
26
+ @mutex.synchronize { @store.delete(key.to_sym) }
27
+ end
28
+
29
+ def clear!
30
+ @mutex.synchronize { @store.clear }
31
+ end
32
+
33
+ def keys
34
+ @mutex.synchronize { @store.keys }
35
+ end
36
+
37
+ def empty?
38
+ @mutex.synchronize { @store.empty? }
39
+ end
40
+ end
41
+ end
42
+ end