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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Spurline
6
+ module CLI
7
+ module Generators
8
+ # Generates a new tool class file.
9
+ # Usage: spur generate tool web_scraper
10
+ class Tool
11
+ attr_reader :name
12
+
13
+ def initialize(name:)
14
+ @name = name.to_s
15
+ end
16
+
17
+ def generate!
18
+ path = File.join("app", "tools", "#{snake_name}.rb")
19
+
20
+ if File.exist?(path)
21
+ $stderr.puts "File already exists: #{path}"
22
+ exit 1
23
+ end
24
+
25
+ FileUtils.mkdir_p(File.dirname(path))
26
+ File.write(path, tool_template)
27
+ puts " create #{path}"
28
+
29
+ spec_path = File.join("spec", "tools", "#{snake_name}_spec.rb")
30
+ FileUtils.mkdir_p(File.dirname(spec_path))
31
+ File.write(spec_path, spec_template)
32
+ puts " create #{spec_path}"
33
+ end
34
+
35
+ private
36
+
37
+ def tool_template
38
+ <<~RUBY
39
+ # frozen_string_literal: true
40
+
41
+ class #{class_name} < Spurline::Tools::Base
42
+ tool_name :#{snake_name}
43
+ description "TODO: Describe what #{snake_name} does"
44
+ parameters({
45
+ type: "object",
46
+ properties: {
47
+ input: { type: "string", description: "TODO: describe input" },
48
+ },
49
+ required: %w[input],
50
+ })
51
+
52
+ def call(input:)
53
+ # TODO: Implement #{snake_name}
54
+ raise NotImplementedError, "#{class_name}#call not yet implemented"
55
+ end
56
+ end
57
+ RUBY
58
+ end
59
+
60
+ def spec_template
61
+ <<~RUBY
62
+ # frozen_string_literal: true
63
+
64
+ require_relative "../../app/tools/#{snake_name}"
65
+
66
+ RSpec.describe #{class_name} do
67
+ let(:tool) { described_class.new }
68
+
69
+ describe "#call" do
70
+ it "executes the tool" do
71
+ # TODO: Write tests for #{snake_name}
72
+ pending "implement #{class_name}#call first"
73
+ result = tool.call(input: "test")
74
+ expect(result).not_to be_nil
75
+ end
76
+ end
77
+ end
78
+ RUBY
79
+ end
80
+
81
+ def class_name
82
+ name.to_s
83
+ .gsub(/[-_]/, " ")
84
+ .split(" ")
85
+ .map(&:capitalize)
86
+ .join
87
+ end
88
+
89
+ def snake_name
90
+ name.to_s
91
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
92
+ .gsub(/[-\s]/, "_")
93
+ .downcase
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -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