spurline-dashboard 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/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 +218 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
# Routes CLI commands to the appropriate handler.
|
|
6
|
+
# Entry point: Router.run(ARGV)
|
|
7
|
+
class Router
|
|
8
|
+
COMMANDS = {
|
|
9
|
+
"new" => :handle_new,
|
|
10
|
+
"generate" => :handle_generate,
|
|
11
|
+
"check" => :handle_check,
|
|
12
|
+
"console" => :handle_console,
|
|
13
|
+
"credentials:edit" => :handle_credentials_edit,
|
|
14
|
+
"version" => :handle_version,
|
|
15
|
+
"help" => :handle_help,
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
GENERATE_SUBCOMMANDS = %w[agent tool migration].freeze
|
|
19
|
+
|
|
20
|
+
def self.run(args)
|
|
21
|
+
new(args).dispatch
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(args)
|
|
25
|
+
@args = args
|
|
26
|
+
@command = args.first
|
|
27
|
+
@rest = args[1..] || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dispatch
|
|
31
|
+
if @command.nil? || @command == "help" || @command == "--help" || @command == "-h"
|
|
32
|
+
handle_help
|
|
33
|
+
elsif @command == "version" || @command == "--version" || @command == "-v"
|
|
34
|
+
handle_version
|
|
35
|
+
elsif COMMANDS.key?(@command)
|
|
36
|
+
send(COMMANDS[@command])
|
|
37
|
+
else
|
|
38
|
+
$stderr.puts "Unknown command: #{@command}"
|
|
39
|
+
$stderr.puts "Run 'spur help' for available commands."
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def handle_new
|
|
47
|
+
project_name = @rest.first
|
|
48
|
+
unless project_name
|
|
49
|
+
$stderr.puts "Usage: spur new <project_name>"
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Generators::Project.new(name: project_name).generate!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_generate
|
|
57
|
+
subcommand = @rest.first
|
|
58
|
+
name = @rest[1]
|
|
59
|
+
|
|
60
|
+
unless subcommand && GENERATE_SUBCOMMANDS.include?(subcommand)
|
|
61
|
+
$stderr.puts "Usage: spur generate <#{GENERATE_SUBCOMMANDS.join("|")}> <name>"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
unless name
|
|
66
|
+
$stderr.puts "Usage: spur generate #{subcommand} <name>"
|
|
67
|
+
exit 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
case subcommand
|
|
71
|
+
when "agent"
|
|
72
|
+
Generators::Agent.new(name: name).generate!
|
|
73
|
+
when "tool"
|
|
74
|
+
Generators::Tool.new(name: name).generate!
|
|
75
|
+
when "migration"
|
|
76
|
+
Generators::Migration.new(name: name).generate!
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_version
|
|
81
|
+
puts "spur #{Spurline::VERSION}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_check
|
|
85
|
+
verbose = @rest.include?("--verbose") || @rest.include?("-v")
|
|
86
|
+
results = Check.new(project_root: Dir.pwd, verbose: verbose).run!
|
|
87
|
+
failures = results.count { |result| result.status == :fail }
|
|
88
|
+
exit(failures.positive? ? 1 : 0)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def handle_console
|
|
92
|
+
verbose = @rest.include?("--verbose") || @rest.include?("-v")
|
|
93
|
+
Console.new(project_root: Dir.pwd, verbose: verbose).start!
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_credentials_edit
|
|
97
|
+
Credentials.new(project_root: Dir.pwd).edit!
|
|
98
|
+
puts "Saved encrypted credentials to config/credentials.enc.yml"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_help
|
|
102
|
+
puts <<~HELP
|
|
103
|
+
spur — Spurline CLI
|
|
104
|
+
|
|
105
|
+
Commands:
|
|
106
|
+
spur new <project> Create a new Spurline agent project
|
|
107
|
+
spur generate agent <name> Generate a new agent class
|
|
108
|
+
spur generate tool <name> Generate a new tool class
|
|
109
|
+
spur generate migration <name> Generate a SQL migration (e.g. sessions)
|
|
110
|
+
spur check Validate project configuration
|
|
111
|
+
spur console Interactive REPL with project loaded
|
|
112
|
+
spur credentials:edit Edit encrypted credentials
|
|
113
|
+
spur version Show version
|
|
114
|
+
spur help Show this help
|
|
115
|
+
|
|
116
|
+
https://github.com/dylanwilcox/spurline
|
|
117
|
+
HELP
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-configurable"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
class Configuration
|
|
7
|
+
extend Dry::Configurable
|
|
8
|
+
|
|
9
|
+
setting :session_store, default: :memory
|
|
10
|
+
setting :session_store_path, default: "tmp/spurline_sessions.db"
|
|
11
|
+
setting :session_store_postgres_url, default: nil
|
|
12
|
+
setting :default_model, default: :claude_sonnet
|
|
13
|
+
setting :log_level, default: :info
|
|
14
|
+
setting :audit_mode, default: :full
|
|
15
|
+
setting :audit_max_entries, default: nil
|
|
16
|
+
setting :idempotency_default_ttl, default: 86_400
|
|
17
|
+
setting :permissions_file, default: "config/permissions.yml"
|
|
18
|
+
setting :brave_api_key, default: nil
|
|
19
|
+
setting :cartographer_exclude_patterns, default: %w[
|
|
20
|
+
.git node_modules vendor tmp log coverage
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3782 | 10:23 PM | 🔵 | Spurline session and state machine architecture analyzed for suspended sessions feature | ~700 |
|
|
11
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for configuring security guardrails.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
# Misconfiguration raises ConfigurationError at class load time.
|
|
8
|
+
module Guardrails
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(ClassMethods)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
def guardrails(&block)
|
|
15
|
+
@guardrail_config ||= GuardrailConfig.new
|
|
16
|
+
@guardrail_config.instance_eval(&block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def guardrail_config
|
|
20
|
+
own = @guardrail_config
|
|
21
|
+
if own
|
|
22
|
+
own
|
|
23
|
+
elsif superclass.respond_to?(:guardrail_config)
|
|
24
|
+
superclass.guardrail_config
|
|
25
|
+
else
|
|
26
|
+
GuardrailConfig.new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class GuardrailConfig
|
|
32
|
+
INJECTION_LEVELS = %i[strict moderate permissive].freeze
|
|
33
|
+
PII_MODES = %i[redact block warn off].freeze
|
|
34
|
+
AUDIT_MODES = %i[full errors_only off].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :settings
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@settings = {
|
|
40
|
+
injection_filter: :strict,
|
|
41
|
+
pii_filter: :off,
|
|
42
|
+
max_tool_calls: 10,
|
|
43
|
+
max_turns: 50,
|
|
44
|
+
audit_max_entries: nil,
|
|
45
|
+
denied_domains: [],
|
|
46
|
+
audit: :full,
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def injection_filter(level)
|
|
51
|
+
validate_inclusion!(:injection_filter, level, INJECTION_LEVELS)
|
|
52
|
+
@settings[:injection_filter] = level
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pii_filter(mode)
|
|
56
|
+
validate_inclusion!(:pii_filter, mode, PII_MODES)
|
|
57
|
+
@settings[:pii_filter] = mode
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def max_tool_calls(n)
|
|
61
|
+
validate_positive_integer!(:max_tool_calls, n)
|
|
62
|
+
@settings[:max_tool_calls] = n
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def max_turns(n)
|
|
66
|
+
validate_positive_integer!(:max_turns, n)
|
|
67
|
+
@settings[:max_turns] = n
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def audit_max_entries(n)
|
|
71
|
+
validate_positive_integer!(:audit_max_entries, n)
|
|
72
|
+
@settings[:audit_max_entries] = n
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def denied_domains(domains)
|
|
76
|
+
@settings[:denied_domains] = Array(domains)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def audit(mode)
|
|
80
|
+
validate_inclusion!(:audit, mode, AUDIT_MODES)
|
|
81
|
+
@settings[:audit] = mode
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_h
|
|
85
|
+
@settings.dup
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def validate_inclusion!(name, value, valid_values)
|
|
91
|
+
return if valid_values.include?(value)
|
|
92
|
+
|
|
93
|
+
raise Spurline::ConfigurationError,
|
|
94
|
+
"Invalid guardrail value for #{name}: #{value.inspect}. " \
|
|
95
|
+
"Must be one of: #{valid_values.map(&:inspect).join(", ")}."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_positive_integer!(name, value)
|
|
99
|
+
return if value.is_a?(Integer) && value.positive?
|
|
100
|
+
|
|
101
|
+
raise Spurline::ConfigurationError,
|
|
102
|
+
"Invalid guardrail value for #{name}: #{value.inspect}. " \
|
|
103
|
+
"Must be a positive integer."
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for registering lifecycle event hooks.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Hooks
|
|
8
|
+
HOOK_TYPES = %i[
|
|
9
|
+
on_start
|
|
10
|
+
on_turn_start
|
|
11
|
+
on_tool_call
|
|
12
|
+
on_turn_end
|
|
13
|
+
on_suspend
|
|
14
|
+
on_resume
|
|
15
|
+
on_finish
|
|
16
|
+
on_error
|
|
17
|
+
on_child_spawn
|
|
18
|
+
on_child_complete
|
|
19
|
+
on_child_error
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def self.included(base)
|
|
23
|
+
base.extend(ClassMethods)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module ClassMethods
|
|
27
|
+
HOOK_TYPES.each do |hook_type|
|
|
28
|
+
define_method(hook_type) do |&block|
|
|
29
|
+
@hooks ||= {}
|
|
30
|
+
@hooks[hook_type] ||= []
|
|
31
|
+
@hooks[hook_type] << block
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def hooks_config
|
|
36
|
+
own = @hooks || {}
|
|
37
|
+
if superclass.respond_to?(:hooks_config)
|
|
38
|
+
inherited = superclass.hooks_config
|
|
39
|
+
merged = inherited.dup
|
|
40
|
+
own.each do |type, blocks|
|
|
41
|
+
merged[type] = (merged[type] || []) + blocks
|
|
42
|
+
end
|
|
43
|
+
merged
|
|
44
|
+
else
|
|
45
|
+
own
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for configuring agent memory stores.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Memory
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def memory(type, **options)
|
|
14
|
+
@memory_config ||= {}
|
|
15
|
+
@memory_config[type.to_sym] = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Shorthand for toggling episodic memory recording.
|
|
19
|
+
#
|
|
20
|
+
# episodic false
|
|
21
|
+
# episodic true
|
|
22
|
+
#
|
|
23
|
+
def episodic(enabled = true)
|
|
24
|
+
@memory_config ||= {}
|
|
25
|
+
@memory_config[:episodic] = { enabled: !!enabled }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def memory_config
|
|
29
|
+
own = @memory_config || {}
|
|
30
|
+
if superclass.respond_to?(:memory_config)
|
|
31
|
+
superclass.memory_config.merge(own)
|
|
32
|
+
else
|
|
33
|
+
own
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for configuring which LLM model an agent uses.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Model
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def use_model(name, **options)
|
|
14
|
+
@model_config = { name: name.to_sym, **options }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def model_config
|
|
18
|
+
@model_config || (superclass.respond_to?(:model_config) ? superclass.model_config : nil)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for defining agent personas (system prompts).
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Persona
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def persona(name = :default, &block)
|
|
14
|
+
@persona_configs ||= {}
|
|
15
|
+
config = PersonaConfig.new
|
|
16
|
+
config.instance_eval(&block)
|
|
17
|
+
@persona_configs[name.to_sym] = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def persona_configs
|
|
21
|
+
own = @persona_configs || {}
|
|
22
|
+
inherited = if superclass.respond_to?(:persona_configs)
|
|
23
|
+
superclass.persona_configs
|
|
24
|
+
else
|
|
25
|
+
{}
|
|
26
|
+
end
|
|
27
|
+
inherited.merge(own)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Internal config object for persona DSL blocks.
|
|
32
|
+
class PersonaConfig
|
|
33
|
+
attr_reader :system_prompt_text
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@system_prompt_text = ""
|
|
37
|
+
@_inject_date = false
|
|
38
|
+
@_inject_user_context = false
|
|
39
|
+
@_inject_agent_context = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def system_prompt(text)
|
|
43
|
+
@system_prompt_text = text
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# DSL setters (used inside persona blocks)
|
|
47
|
+
def inject_date(val = true)
|
|
48
|
+
@_inject_date = val
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inject_user_context(val = true)
|
|
52
|
+
@_inject_user_context = val
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def inject_agent_context(val = true)
|
|
56
|
+
@_inject_agent_context = val
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Predicate readers (used when compiling persona config)
|
|
60
|
+
def date_injected?
|
|
61
|
+
@_inject_date
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def user_context_injected?
|
|
65
|
+
@_inject_user_context
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def agent_context_injected?
|
|
69
|
+
@_inject_agent_context
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../lifecycle/suspension_boundary"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module DSL
|
|
7
|
+
module SuspendUntil
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
# Declares a suspension condition for this agent class.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# suspend_until :tool_calls, count: 3
|
|
17
|
+
# suspend_until :custom, &block
|
|
18
|
+
def suspend_until(type = nil, **options, &block)
|
|
19
|
+
@suspension_config = { type: type, options: options, block: block }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def suspension_config
|
|
23
|
+
@suspension_config || superclass_suspension_config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Builds a SuspensionCheck from the declarative config.
|
|
27
|
+
def build_suspension_check
|
|
28
|
+
config = suspension_config
|
|
29
|
+
return Lifecycle::SuspensionCheck.none unless config
|
|
30
|
+
|
|
31
|
+
case config[:type]
|
|
32
|
+
when :tool_calls
|
|
33
|
+
Lifecycle::SuspensionCheck.after_tool_calls(config[:options][:count])
|
|
34
|
+
when :custom
|
|
35
|
+
Lifecycle::SuspensionCheck.new(&config[:block])
|
|
36
|
+
else
|
|
37
|
+
Lifecycle::SuspensionCheck.none
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def superclass_suspension_config
|
|
44
|
+
if superclass.respond_to?(:suspension_config)
|
|
45
|
+
superclass.suspension_config
|
|
46
|
+
else
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "pathname"
|
|
3
|
+
|
|
4
|
+
module Spurline
|
|
5
|
+
module DSL
|
|
6
|
+
# DSL for declaring which tools an agent can use.
|
|
7
|
+
# Registers configuration at class load time — never executes behavior.
|
|
8
|
+
#
|
|
9
|
+
# Supports per-tool config overrides:
|
|
10
|
+
# tools :web_search, file_delete: { requires_confirmation: true, timeout: 30 }
|
|
11
|
+
module Tools
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module ClassMethods
|
|
17
|
+
IDEMPOTENCY_OPTION_KEYS = %i[
|
|
18
|
+
idempotent
|
|
19
|
+
idempotency_key
|
|
20
|
+
idempotency_ttl
|
|
21
|
+
idempotency_key_fn
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def tools(*tool_names, **tool_configs)
|
|
25
|
+
@tool_config ||= { names: [], configs: {} }
|
|
26
|
+
tool_names.each { |name| @tool_config[:names] << name.to_sym }
|
|
27
|
+
tool_configs.each do |name, config|
|
|
28
|
+
@tool_config[:names] << name.to_sym
|
|
29
|
+
existing = @tool_config[:configs][name.to_sym]
|
|
30
|
+
@tool_config[:configs][name.to_sym] = existing ? existing.merge(config) : config
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Include one or more toolkits by name. Toolkit expansion is deferred
|
|
35
|
+
# until tool_config is accessed, so toolkits can be registered after
|
|
36
|
+
# agent classes are defined (supports any boot order).
|
|
37
|
+
#
|
|
38
|
+
# toolkits :git, :linear
|
|
39
|
+
# toolkits :provisioning, provisioning: { scoped: true }
|
|
40
|
+
#
|
|
41
|
+
def toolkits(*toolkit_names, **overrides)
|
|
42
|
+
@pending_toolkits ||= []
|
|
43
|
+
@pending_toolkits << { names: toolkit_names.map(&:to_sym), overrides: overrides }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tool_config
|
|
47
|
+
expand_pending_toolkits!
|
|
48
|
+
own = @tool_config || { names: [], configs: {} }
|
|
49
|
+
if superclass.respond_to?(:tool_config)
|
|
50
|
+
inherited = superclass.tool_config
|
|
51
|
+
{
|
|
52
|
+
names: (inherited[:names] + own[:names]).uniq,
|
|
53
|
+
configs: inherited[:configs].merge(own[:configs]),
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
own
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns per-tool configuration for a specific tool.
|
|
61
|
+
def tool_config_for(tool_name)
|
|
62
|
+
tool_config[:configs][tool_name.to_sym] || {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Effective per-tool idempotency options from DSL config.
|
|
66
|
+
def idempotency_config
|
|
67
|
+
tool_config[:configs].each_with_object({}) do |(tool_name, config), result|
|
|
68
|
+
next unless config.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
options = symbolize_hash(config).slice(*IDEMPOTENCY_OPTION_KEYS)
|
|
71
|
+
next if options.empty?
|
|
72
|
+
|
|
73
|
+
result[tool_name.to_sym] = options
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Effective permissions applied by Tools::Runner.
|
|
78
|
+
# Merge order: spur defaults -> agent inline config -> YAML overrides.
|
|
79
|
+
def permissions_config
|
|
80
|
+
merged = {}
|
|
81
|
+
deep_merge_permissions!(merged, spur_default_permissions)
|
|
82
|
+
deep_merge_permissions!(merged, inline_tool_permissions)
|
|
83
|
+
deep_merge_permissions!(merged, yaml_permissions)
|
|
84
|
+
merged
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def expand_pending_toolkits!
|
|
90
|
+
return unless instance_variable_defined?(:@pending_toolkits) && @pending_toolkits&.any?
|
|
91
|
+
|
|
92
|
+
pending = @pending_toolkits
|
|
93
|
+
@pending_toolkits = nil
|
|
94
|
+
|
|
95
|
+
pending.each do |ref|
|
|
96
|
+
ref[:names].each do |tk_name|
|
|
97
|
+
toolkit = self.toolkit_registry.fetch(tk_name)
|
|
98
|
+
|
|
99
|
+
# Toolkits own their tools — register them into the agent's tool registry.
|
|
100
|
+
toolkit.tool_classes.each do |tool_name, tool_class|
|
|
101
|
+
self.tool_registry.register(tool_name, tool_class) unless self.tool_registry.registered?(tool_name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
tool_names = toolkit.tools
|
|
105
|
+
shared = toolkit.shared_config
|
|
106
|
+
|
|
107
|
+
if shared.empty? && ref[:overrides].empty?
|
|
108
|
+
tools(*tool_names)
|
|
109
|
+
else
|
|
110
|
+
tool_configs = {}
|
|
111
|
+
tool_names.each do |tool_name|
|
|
112
|
+
merged = shared.dup
|
|
113
|
+
merged.merge!(ref[:overrides][tk_name] || {})
|
|
114
|
+
tool_configs[tool_name] = merged unless merged.empty?
|
|
115
|
+
end
|
|
116
|
+
tools(*tool_names, **tool_configs)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def spur_default_permissions
|
|
123
|
+
return {} unless defined?(Spurline::Spur)
|
|
124
|
+
|
|
125
|
+
Spurline::Spur.registry.each_with_object({}) do |(_name, info), result|
|
|
126
|
+
next unless info.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
defaults = symbolize_hash(info[:permissions] || info["permissions"])
|
|
129
|
+
next if defaults.empty?
|
|
130
|
+
|
|
131
|
+
tools = info[:tools] || info["tools"] || []
|
|
132
|
+
tools.each do |tool_name|
|
|
133
|
+
result[tool_name.to_sym] ||= {}
|
|
134
|
+
result[tool_name.to_sym].merge!(defaults)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def inline_tool_permissions
|
|
140
|
+
tool_config[:configs].each_with_object({}) do |(tool_name, config), result|
|
|
141
|
+
result[tool_name.to_sym] = symbolize_hash(config)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def yaml_permissions
|
|
146
|
+
path = resolve_permissions_path
|
|
147
|
+
Spurline::Tools::Permissions.load_file(path)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resolve_permissions_path
|
|
151
|
+
configured = Spurline.config.permissions_file
|
|
152
|
+
return nil if configured.nil? || configured.to_s.strip.empty?
|
|
153
|
+
return configured if Pathname.new(configured).absolute?
|
|
154
|
+
|
|
155
|
+
File.expand_path(configured.to_s, Dir.pwd)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def deep_merge_permissions!(base, incoming)
|
|
159
|
+
incoming.each do |tool_name, tool_config_hash|
|
|
160
|
+
key = tool_name.to_sym
|
|
161
|
+
base[key] ||= {}
|
|
162
|
+
base[key].merge!(symbolize_hash(tool_config_hash))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def symbolize_hash(value)
|
|
167
|
+
return {} unless value.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
value.each_with_object({}) do |(k, v), result|
|
|
170
|
+
result[k.to_sym] = v
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|