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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/CLAUDE.md +11 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/CLAUDE.md +11 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/CLAUDE.md +11 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/CLAUDE.md +18 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/CLAUDE.md +11 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/CLAUDE.md +12 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/CLAUDE.md +12 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- 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
|