riffer-rig 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bc260756e56f8ed0d89556ef071a3ecb1922b637eb5b10cc0268416e0ceb26d
4
- data.tar.gz: b7e6d6314a893ff23f289336b5e3df25f11e032f060272d7656695f1e96e584d
3
+ metadata.gz: 5f79d98c522224290937a01b701fa7f4ac72f2be08fbbb39abf32f06691880f7
4
+ data.tar.gz: 3c66dbd81e98e86b1c1132094431e0803d23c757a950684c6663ad5abd8c3369
5
5
  SHA512:
6
- metadata.gz: edda5ec3d1675966361db52a009328703f0f62340d8d18ea3f5e689f3e9194762fec33804072989f1cb92d357038e03ca9481e7ab9f34eb123964f3f025b8749
7
- data.tar.gz: c1953f30afa3a7871291852d3aaeba52a256006732582c82f71ad3245234f1e2a974524b174a801df81096af6effc37186e3a10e11ace5f5be644edbe4ed1c49
6
+ metadata.gz: 6087cb7527548ca826bf04ab3b214c36ab29e18c676c62f7df84377010bb47fddae9e88b1354d1fa8815efa4967d4599ccc63e022c57660a567b58e226554679
7
+ data.tar.gz: 2aae83bea0978c35b33674149527c0281baeb33f7bf186ba38d9b6abdeb905a3649989eae97430d7eae6284a80a253152d0532c8ba3d75f3ae3d7adee6f6a757
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/bottrall/riffer-rig/compare/v0.10.0...v0.11.0) (2026-10-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * riffer -p runs one prompt headless with exit codes ([#226](https://github.com/bottrall/riffer-rig/issues/226)) ([eaa91d6](https://github.com/bottrall/riffer-rig/commit/eaa91d625eb303e4dc8a08c317ea38fc136a729d)), closes [#137](https://github.com/bottrall/riffer-rig/issues/137)
9
+ * store entries as typed POROs at the store boundary ([#225](https://github.com/bottrall/riffer-rig/issues/225)) ([05431e5](https://github.com/bottrall/riffer-rig/commit/05431e583c412ee7c3551dadd61db1089af465a9))
10
+
3
11
  ## [0.10.0](https://github.com/bottrall/riffer-rig/compare/v0.9.0...v0.10.0) (2026-10-05)
4
12
 
5
13
 
data/README.md CHANGED
@@ -51,7 +51,21 @@ The model is the first of these that is set: `--model`, `RIFFER_MODEL`, `model`
51
51
  | `-r ID`, `--resume ID` | resumes the session with id `ID` ([Sessions](docs/SESSIONS.md#resuming)) |
52
52
  | `-h`, `--help` | prints the usage |
53
53
 
54
- `riffer -p` (one prompt, no terminal) and `riffer acp` (an editor's agent over stdio) are reserved: for now each prints the usage and exits with status 2.
54
+ `riffer acp` (an editor's agent over stdio) is reserved: for now it prints the usage and exits with status 2.
55
+
56
+ ### Headless
57
+
58
+ Run one prompt with no terminal ([Headless](docs/HEADLESS.md)):
59
+
60
+ ```bash
61
+ riffer -p "what does this repo do"
62
+ git diff | riffer -p "review this diff" # piped stdin appended as context
63
+ echo "explain this diff" | riffer -p # piped stdin alone is the prompt
64
+ ```
65
+
66
+ Everything loads exactly as the REPL does, the shared flags work the same — `-c` and `-r` included: `riffer -p -c "now run the tests"` continues the most recent session in this directory, and a missing session exits 2 ([Headless](docs/HEADLESS.md)). The session is saved like any other unless `--no-save`. Only the assistant's text is printed, streamed to stdout; errors and warnings go to stderr, and `--verbose` adds a one-line trace of each tool call there. Headless never prompts: a missing model, credential, or SDK prints the reason on stderr and exits 2.
67
+
68
+ The exit code says how the turn ended: `0` finished, `1` runtime error, `2` usage or configuration, `3` the turn ended without completing (`--max-steps`, a full context window, a provider content filter), `130` SIGINT (the turn is cancelled first).
55
69
 
56
70
  ### In the session
57
71
 
@@ -5,25 +5,28 @@ require 'optparse'
5
5
  class Riffer::Rig::CLI::Flags
6
6
  BANNER = <<~TEXT.chomp #: String
7
7
  Usage: riffer [options]
8
- riffer -p [prompt] (not available yet)
8
+ riffer -p [prompt] run one prompt headless and exit
9
9
  riffer acp (not available yet)
10
10
  TEXT
11
11
 
12
- # @dynamic model, extensions, skills, agents_md, tools, max_steps, help, save, continue, resume
12
+ # @dynamic model, extensions, skills, agents_md, tools, max_steps, help, save, verbose, prompt, continue, resume
13
13
  attr_reader :model #: String?
14
- attr_reader :extensions, :skills, :agents_md, :help #: bool
14
+ attr_reader :extensions, :skills, :agents_md, :help, :verbose #: bool
15
15
  attr_reader :tools #: Array[String]?
16
16
  attr_reader :max_steps #: Integer?
17
17
  attr_reader :save #: bool
18
+ attr_reader :prompt #: String?
18
19
  attr_reader :continue #: bool
19
20
  attr_reader :resume #: String?
20
21
 
21
22
  # @rbs argv: Array[String]
23
+ # @rbs prompt: bool
22
24
  # @rbs return: Riffer::Rig::CLI::Flags | String
23
- def self.parse(argv)
25
+ def self.parse(argv, prompt: false)
24
26
  values = {} #: Hash[Symbol, untyped]
25
27
  rest = parser.parse(argv, into: values)
26
- return "unexpected argument: #{rest.join(' ')}" unless rest.empty?
28
+ allowed = rest.empty? || (prompt && rest.size == 1)
29
+ return "unexpected argument: #{rest.join(' ')}" unless allowed
27
30
 
28
31
  new(
29
32
  model: values[:model],
@@ -33,6 +36,8 @@ class Riffer::Rig::CLI::Flags
33
36
  tools: values[:tools],
34
37
  max_steps: values[:'max-steps'],
35
38
  save: !values.key?(:'no-save'),
39
+ verbose: values.key?(:verbose),
40
+ prompt: prompt ? rest.first : nil,
36
41
  continue: values.key?(:continue),
37
42
  resume: values[:resume],
38
43
  help: values.key?(:help)
@@ -60,6 +65,8 @@ class Riffer::Rig::CLI::Flags
60
65
  parser.on('--no-save', 'Do not save this session')
61
66
  parser.on('-c', '--continue', 'Continue the most recent session in this directory')
62
67
  parser.on('-r ID', '--resume ID', String, 'Resume the session with this id')
68
+ parser.on('-p', '--print', 'Run one prompt headless and exit; the prompt is the argument, else stdin')
69
+ parser.on('--verbose', 'With -p, trace each tool call to stderr')
63
70
  parser.on('-h', '--help', 'Show this help')
64
71
  end
65
72
  end
@@ -72,6 +79,8 @@ class Riffer::Rig::CLI::Flags
72
79
  # @rbs tools: Array[String]?
73
80
  # @rbs max_steps: Integer?
74
81
  # @rbs save: bool
82
+ # @rbs verbose: bool
83
+ # @rbs prompt: String?
75
84
  # @rbs continue: bool
76
85
  # @rbs resume: String?
77
86
  # @rbs help: bool
@@ -84,6 +93,8 @@ class Riffer::Rig::CLI::Flags
84
93
  tools: nil,
85
94
  max_steps: nil,
86
95
  save: true,
96
+ verbose: false,
97
+ prompt: nil,
87
98
  continue: false,
88
99
  resume: nil,
89
100
  help: false
@@ -95,6 +106,8 @@ class Riffer::Rig::CLI::Flags
95
106
  @tools = tools&.freeze
96
107
  @max_steps = max_steps
97
108
  @save = save
109
+ @verbose = verbose
110
+ @prompt = prompt
98
111
  @continue = continue
99
112
  @resume = resume
100
113
  @help = help
@@ -27,17 +27,43 @@ module Riffer::Rig::CLI
27
27
  home: Dir.home
28
28
  )
29
29
  return unavailable("riffer #{ACP_COMMAND}", error) if argv.first == ACP_COMMAND
30
- return unavailable('riffer -p', error) if argv.intersect?(PRINT_FLAGS)
31
30
 
32
- flags = Flags.parse(argv)
31
+ headless_mode = argv.intersect?(PRINT_FLAGS)
32
+ flags = Flags.parse(argv, prompt: headless_mode)
33
33
  return refuse(flags, error) if flags.is_a?(String)
34
34
  return help(output) if flags.help
35
+ return refuse('--verbose is only available with -p', error) if flags.verbose && !headless_mode
36
+
37
+ return headless(flags, input:, output:, error:, env:, cwd:, home:) if headless_mode
35
38
 
36
39
  terminal(flags, input:, output:, env:, cwd:, home:)
37
40
  end
38
41
 
39
42
  private
40
43
 
44
+ # @rbs flags: Riffer::Rig::CLI::Flags
45
+ # @rbs input: IO
46
+ # @rbs output: IO
47
+ # @rbs error: IO
48
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
49
+ # @rbs cwd: String
50
+ # @rbs home: String
51
+ # @rbs return: Integer
52
+ def headless(flags, input:, output:, error:, env:, cwd:, home:)
53
+ Riffer::Rig::Headless.for(
54
+ input: input,
55
+ output: output,
56
+ error: error,
57
+ verbose: flags.verbose
58
+ ).run(prompt: flags.prompt) do |host|
59
+ loader = new_loader(flags, host: host, env: env, cwd: cwd, home: home)
60
+ started = find_session(flags, loader)
61
+ raise Riffer::Rig::Loader::ConfigurationError, missing_session(flags) if started.nil? && session_flags?(flags)
62
+
63
+ started || build(loader, flags)
64
+ end
65
+ end
66
+
41
67
  # @rbs flags: Riffer::Rig::CLI::Flags
42
68
  # @rbs input: IO
43
69
  # @rbs output: IO
@@ -52,33 +78,53 @@ module Riffer::Rig::CLI
52
78
  version: Riffer::Rig::VERSION,
53
79
  no_color: env.is_a?(Riffer::Rig::Env) && env.no_color
54
80
  ).run do |host|
55
- loader = Riffer::Rig::Loader.new(
56
- cwd: cwd,
57
- host: host,
58
- env: env,
59
- home: home,
60
- store: flags.save ? Riffer::Rig::Stores::JSONL.new : nil
61
- )
62
- resumed(flags, host, loader) || build(loader, flags)
81
+ loader = new_loader(flags, host: host, env: env, cwd: cwd, home: home)
82
+ started = find_session(flags, loader)
83
+ host.notify(missing_session(flags)) if started.nil? && session_flags?(flags)
84
+
85
+ started || build(loader, flags)
63
86
  end
64
87
  end
65
88
 
66
89
  # @rbs flags: Riffer::Rig::CLI::Flags
67
90
  # @rbs host: Riffer::Rig::Hosts::_Host
91
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
92
+ # @rbs cwd: String
93
+ # @rbs home: String
94
+ # @rbs return: Riffer::Rig::Loader
95
+ def new_loader(flags, host:, env:, cwd:, home:)
96
+ Riffer::Rig::Loader.new(
97
+ cwd: cwd,
98
+ host: host,
99
+ env: env,
100
+ home: home,
101
+ store: flags.save ? Riffer::Rig::Stores::JSONL.new : nil
102
+ )
103
+ end
104
+
105
+ # @rbs flags: Riffer::Rig::CLI::Flags
68
106
  # @rbs loader: Riffer::Rig::Loader
69
107
  # @rbs return: Riffer::Rig::Runtime?
70
- def resumed(flags, host, loader)
108
+ def find_session(flags, loader)
71
109
  if flags.resume
72
- started = loader.resume(flags.resume, **keywords(flags))
73
- host.notify("No saved session #{flags.resume}.") unless started
74
- started
110
+ loader.resume(flags.resume, **keywords(flags))
75
111
  elsif flags.continue
76
- started = loader.continue(**keywords(flags))
77
- host.notify('No saved session in this directory.') unless started
78
- started
112
+ loader.continue(**keywords(flags))
79
113
  end
80
114
  end
81
115
 
116
+ # @rbs flags: Riffer::Rig::CLI::Flags
117
+ # @rbs return: bool
118
+ def session_flags?(flags)
119
+ flags.resume ? true : flags.continue
120
+ end
121
+
122
+ # @rbs flags: Riffer::Rig::CLI::Flags
123
+ # @rbs return: String
124
+ def missing_session(flags)
125
+ flags.resume ? "No saved session #{flags.resume}." : 'No saved session in this directory.'
126
+ end
127
+
82
128
  # @rbs loader: Riffer::Rig::Loader
83
129
  # @rbs flags: Riffer::Rig::CLI::Flags
84
130
  # @rbs return: Riffer::Rig::Runtime
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Headless::Host
4
+ CAPABILITIES = Set[:notify, :progress].freeze #: Set[Symbol]
5
+
6
+ # @rbs @error: IO
7
+
8
+ # @rbs error: IO
9
+ # @rbs return: void
10
+ def initialize(error: $stderr)
11
+ @error = error
12
+ end
13
+
14
+ # @rbs return: Set[Symbol]
15
+ def capabilities
16
+ CAPABILITIES
17
+ end
18
+
19
+ # @rbs question: String?
20
+ # @rbs options: Array[String]?
21
+ # @rbs secret: bool
22
+ # @rbs return: String?
23
+ def ask(question = nil, options: nil, secret: false)
24
+ nil
25
+ end
26
+
27
+ # @rbs question: String?
28
+ # @rbs return: bool
29
+ def confirm(question = nil)
30
+ false
31
+ end
32
+
33
+ # @rbs message: String?
34
+ # @rbs level: Symbol
35
+ # @rbs return: void
36
+ def notify(message = nil, level: :info)
37
+ @error.puts(message.to_s)
38
+ end
39
+
40
+ # @rbs label: String?
41
+ # @rbs &block: ? () -> void
42
+ # @rbs return: void
43
+ def progress(label = nil, &block)
44
+ @error.puts(label) if label
45
+ block&.call
46
+ end
47
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Headless
4
+ USAGE_ERROR = 2 #: Integer
5
+
6
+ # The plan's exit-3 set: the cap plus the provider stop reasons that mean the
7
+ # turn ended without completing. Riffer's remaining stop reasons (:error,
8
+ # :other, :guardrail_blocked) are runtime failures and map to 1.
9
+ STOPPED_WITHOUT_COMPLETING = %i[
10
+ max_steps context_window length content_filter malformed_output
11
+ ].freeze #: Array[Symbol]
12
+
13
+ # @rbs @input: IO
14
+ # @rbs @output: IO
15
+ # @rbs @error: IO
16
+ # @rbs @host: Riffer::Rig::Headless::Host
17
+ # @rbs @verbose: bool
18
+ # @rbs @streamed_text: bool
19
+
20
+ # @rbs input: IO
21
+ # @rbs output: IO
22
+ # @rbs error: IO
23
+ # @rbs verbose: bool
24
+ # @rbs return: Riffer::Rig::Headless
25
+ def self.for(input:, output:, error:, verbose: false)
26
+ host = Riffer::Rig::Headless::Host.new(error: error)
27
+ new(input: input, output: output, error: error, host: host, verbose: verbose)
28
+ end
29
+
30
+ # @rbs input: IO
31
+ # @rbs output: IO
32
+ # @rbs error: IO
33
+ # @rbs host: Riffer::Rig::Headless::Host
34
+ # @rbs verbose: bool
35
+ # @rbs return: void
36
+ def initialize(input:, output:, error:, host:, verbose: false)
37
+ @input = input
38
+ @output = output
39
+ @error = error
40
+ @host = host
41
+ @verbose = verbose
42
+ end
43
+
44
+ # @rbs prompt: String?
45
+ # @rbs &build: (Riffer::Rig::Hosts::_Host) -> Riffer::Rig::Runtime
46
+ # @rbs return: Integer
47
+ def run(prompt:, &build)
48
+ text = prompt_text(prompt)
49
+ return usage_error if text.empty?
50
+
51
+ drive(yield(@host), text)
52
+ rescue Riffer::Rig::Loader::ConfigurationError => e
53
+ @error.puts("riffer: #{e.message}")
54
+ USAGE_ERROR
55
+ rescue Interrupt
56
+ 130
57
+ rescue StandardError => e
58
+ @error.puts("riffer: #{e.message}")
59
+ 1
60
+ end
61
+
62
+ private
63
+
64
+ # @rbs prompt: String?
65
+ # @rbs return: String
66
+ def prompt_text(prompt)
67
+ piped = @input.tty? ? '' : @input.read.to_s.strip
68
+ [prompt, piped].compact.reject(&:empty?).join("\n\n")
69
+ end
70
+
71
+ # @rbs return: Integer
72
+ def usage_error
73
+ @error.puts('riffer: no prompt: pass one as an argument, or pipe it to stdin')
74
+ USAGE_ERROR
75
+ end
76
+
77
+ # @rbs runtime: Riffer::Rig::Runtime
78
+ # @rbs text: String
79
+ # @rbs return: Integer
80
+ def drive(runtime, text)
81
+ turn(text, runtime)
82
+ ensure
83
+ runtime.close
84
+ end
85
+
86
+ # @rbs text: String
87
+ # @rbs runtime: Riffer::Rig::Runtime
88
+ # @rbs return: Integer
89
+ def turn(text, runtime)
90
+ # @type var stop_reason: Symbol?
91
+ stop_reason = nil
92
+ # Runtime#cancel takes a Mutex, which Ruby refuses inside a trap handler,
93
+ # so the handler hands the cancel to a thread.
94
+ previous = Signal.trap('INT') { Thread.new { runtime.cancel } }
95
+ begin
96
+ runtime.prompt(text) do |event|
97
+ case event
98
+ when Riffer::Rig::Events::TurnEnd
99
+ stop_reason = event.stop_reason
100
+ else
101
+ handle(event)
102
+ end
103
+ end
104
+ rescue StandardError => e
105
+ @error.puts("riffer: #{e.message}")
106
+ return 1
107
+ ensure
108
+ Signal.trap('INT', previous)
109
+ end
110
+ finish(stop_reason)
111
+ end
112
+
113
+ # @rbs event: ::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event
114
+ # @rbs return: void
115
+ def handle(event)
116
+ case event
117
+ when Riffer::StreamEvents::TextDelta
118
+ @output.write(event.content)
119
+ @streamed_text = true
120
+ when Riffer::StreamEvents::TextDone
121
+ # A provider that streams nothing hands over the whole text here; one
122
+ # that streamed deltas has already written it.
123
+ @output.write(event.content) if !@streamed_text && !event.content.empty?
124
+ @output.puts if @streamed_text || !event.content.empty?
125
+ @streamed_text = false
126
+ when Riffer::StreamEvents::ToolCallDone
127
+ @error.puts("tool_call #{event.name} #{event.arguments}") if @verbose
128
+ end
129
+ end
130
+
131
+ # @rbs stop_reason: Symbol?
132
+ # @rbs return: Integer
133
+ def finish(stop_reason)
134
+ return 0 if stop_reason == :completed
135
+
136
+ @error.puts("turn ended: #{stop_reason}") if stop_reason
137
+ return 130 if stop_reason == Riffer::Rig::Runtime::INTERRUPT_CANCELLED
138
+ return 3 if stop_reason && STOPPED_WITHOUT_COMPLETING.include?(stop_reason)
139
+
140
+ 1
141
+ end
142
+ end
@@ -176,11 +176,10 @@ class Riffer::Rig::Loader
176
176
  store = @store
177
177
  return nil unless store
178
178
 
179
- latest = store.list(cwd: @cwd).max_by { |header| store.updated(header.fetch(:id)) || Time.at(0) }
179
+ latest = store.list(cwd: @cwd).max_by { |header| header.updated || Time.at(0) }
180
180
  return nil unless latest
181
181
 
182
- id = latest.fetch(:id) #: String
183
- resume(id, model:, extensions:, skills:, agents_md:, tools:, max_steps:)
182
+ resume(latest.id, model:, extensions:, skills:, agents_md:, tools:, max_steps:)
184
183
  end
185
184
 
186
185
  # A resume carries the history over and nothing else: the settings,
@@ -212,7 +211,7 @@ class Riffer::Rig::Loader
212
211
  end
213
212
 
214
213
  # @rbs all: bool
215
- # @rbs return: Array[Hash[Symbol, untyped]]
214
+ # @rbs return: Array[::Riffer::Rig::Stores::Header]
216
215
  def list(all: false)
217
216
  store = @store
218
217
  return [] unless store
@@ -563,16 +562,15 @@ class Riffer::Rig::Loader
563
562
  File.join(@home, '.riffer', 'settings.json')
564
563
  end
565
564
 
566
- # @rbs entries: Array[Hash[Symbol, untyped]]
565
+ # @rbs entries: Array[Riffer::Rig::Stores::entry]
567
566
  # @rbs id: String
568
567
  # @rbs return: Hash[Symbol, untyped]
569
568
  def snapshot_of(entries, id)
570
- models = entries.select { |entry| entry[:type] == 'model' }
571
569
  {
572
570
  id: id,
573
- messages: entries.select { |entry| entry[:type] == 'message' }.map { |entry| entry.fetch(:message) },
574
- model: models.last&.fetch(:model),
575
- skills: entries.select { |entry| entry[:type] == 'skill' }.map { |entry| entry.fetch(:skill) }
571
+ messages: entries.filter_map { |entry| entry.message if entry.is_a?(Riffer::Rig::Stores::MessageEntry) },
572
+ model: entries.filter_map { |entry| entry.model if entry.is_a?(Riffer::Rig::Stores::ModelEntry) }.last,
573
+ skills: entries.filter_map { |entry| entry.skill if entry.is_a?(Riffer::Rig::Stores::SkillEntry) }
576
574
  }
577
575
  end
578
576
 
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Stores::Header
4
+ TYPE = 'header' #: String
5
+
6
+ include Riffer::Rig::Support::Equatable
7
+
8
+ # @rbs @schema_version: Integer
9
+ # @rbs @id: String
10
+ # @rbs @cwd: String
11
+ # @rbs @created_at: String
12
+ # @rbs @model: String
13
+ # @rbs @riffer_rig_version: String
14
+ # @rbs @riffer_version: String
15
+ # @rbs @title: String
16
+ # @rbs @updated: Time?
17
+
18
+ # @dynamic schema_version, id, cwd, created_at, model, riffer_rig_version, riffer_version, title, updated
19
+ attr_reader :schema_version #: Integer
20
+ attr_reader :id #: String
21
+ attr_reader :cwd #: String
22
+ attr_reader :created_at #: String
23
+ attr_reader :model #: String
24
+ attr_reader :riffer_rig_version #: String
25
+ attr_reader :riffer_version #: String
26
+ attr_reader :title #: String
27
+ # Store-derived metadata, not entry data: stamped by the store while it
28
+ # holds the physical artifact (the JSONL file's mtime), so it is excluded
29
+ # from to_h.
30
+ attr_reader :updated #: Time?
31
+
32
+ # @rbs hash: Hash[Symbol, untyped]
33
+ # @rbs updated: Time?
34
+ # @rbs return: ::Riffer::Rig::Stores::Header?
35
+ def self.from_hash(hash, updated: nil)
36
+ schema_version = hash[:schema_version]
37
+ id = hash[:id]
38
+ cwd = hash[:cwd]
39
+ created_at = hash[:created_at]
40
+ model = hash[:model]
41
+ riffer_rig_version = hash[:riffer_rig_version]
42
+ riffer_version = hash[:riffer_version]
43
+ title = hash[:title]
44
+ return nil unless schema_version.is_a?(Integer)
45
+ return nil unless [id, cwd, created_at, model, riffer_rig_version, riffer_version, title].all?(String)
46
+
47
+ new(
48
+ schema_version: schema_version,
49
+ id: id,
50
+ cwd: cwd,
51
+ created_at: created_at,
52
+ model: model,
53
+ riffer_rig_version: riffer_rig_version,
54
+ riffer_version: riffer_version,
55
+ title: title,
56
+ updated: updated
57
+ )
58
+ end
59
+
60
+ # @rbs schema_version: Integer
61
+ # @rbs id: String
62
+ # @rbs cwd: String
63
+ # @rbs created_at: String
64
+ # @rbs model: String
65
+ # @rbs riffer_rig_version: String
66
+ # @rbs riffer_version: String
67
+ # @rbs title: String
68
+ # @rbs updated: Time?
69
+ # @rbs return: void
70
+ def initialize(
71
+ schema_version:,
72
+ id:,
73
+ cwd:,
74
+ created_at:,
75
+ model:,
76
+ riffer_rig_version:,
77
+ riffer_version:,
78
+ title:,
79
+ updated: nil
80
+ )
81
+ @schema_version = schema_version
82
+ @id = id
83
+ @cwd = cwd
84
+ @created_at = created_at
85
+ @model = model
86
+ @riffer_rig_version = riffer_rig_version
87
+ @riffer_version = riffer_version
88
+ @title = title
89
+ @updated = updated
90
+ freeze
91
+ end
92
+
93
+ # @rbs return: Hash[Symbol, untyped]
94
+ def to_h
95
+ {
96
+ type: TYPE,
97
+ schema_version: @schema_version,
98
+ id: @id,
99
+ cwd: @cwd,
100
+ created_at: @created_at,
101
+ model: @model,
102
+ riffer_rig_version: @riffer_rig_version,
103
+ riffer_version: @riffer_version,
104
+ title: @title
105
+ }
106
+ end
107
+ end