space-architect 9.2.1 → 9.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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.2.1
4
+ version: 9.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '9.1'
26
- - !ruby/object:Gem::Dependency
27
- name: async-http
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '0.95'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '0.95'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: async-process
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -51,20 +37,6 @@ dependencies:
51
37
  - - "~>"
52
38
  - !ruby/object:Gem::Version
53
39
  version: '1.4'
54
- - !ruby/object:Gem::Dependency
55
- name: protocol-http
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '0.62'
61
- type: :runtime
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '0.62'
68
40
  - !ruby/object:Gem::Dependency
69
41
  name: pastel
70
42
  requirement: !ruby/object:Gem::Requirement
@@ -166,8 +138,7 @@ dependencies:
166
138
  description: 'A dry-cli CLI for the Architect Loop: iterations, briefs, freezes, verdicts,
167
139
  lane worktrees, and headless pi dispatch — the judgment loop that runs inside space-cadet''s
168
140
  task-scoped workspaces. Depends on the space-cadet gem for the spaces substrate
169
- (hard) and the repo-tender gem for the session-sync launchd agent (soft, install
170
- at will).'
141
+ (hard).'
171
142
  email:
172
143
  - eric@ebj.dev
173
144
  executables:
@@ -185,12 +156,10 @@ files:
185
156
  - lib/space_architect/cli.rb
186
157
  - lib/space_architect/cli/architect.rb
187
158
  - lib/space_architect/cli/research.rb
188
- - lib/space_architect/conversations_client.rb
189
159
  - lib/space_architect/extension_installer.rb
190
160
  - lib/space_architect/gate_evaluator.rb
191
161
  - lib/space_architect/gate_lint.rb
192
162
  - lib/space_architect/harness.rb
193
- - lib/space_architect/jobs_client.rb
194
163
  - lib/space_architect/loop_cost.rb
195
164
  - lib/space_architect/looplog.rb
196
165
  - lib/space_architect/pi/builder-guard.ts
@@ -202,16 +171,8 @@ files:
202
171
  - lib/space_architect/research/run.rb
203
172
  - lib/space_architect/research/supervisor.rb
204
173
  - lib/space_architect/routing.rb
205
- - lib/space_architect/run_creator.rb
206
174
  - lib/space_architect/runlog.rb
207
- - lib/space_architect/session_sync.rb
208
- - lib/space_architect/session_sync/bin_path.rb
209
- - lib/space_architect/session_sync/cursor.rb
210
- - lib/space_architect/session_sync/plist.rb
211
- - lib/space_architect/session_sync/runner.rb
212
- - lib/space_architect/session_sync/session_id.rb
213
175
  - lib/space_architect/skill_installer.rb
214
- - lib/space_architect/space_push.rb
215
176
  - lib/space_architect/templates/architect.md.erb
216
177
  - lib/space_architect/templates/brief.md.erb
217
178
  - lib/space_architect/templates/iteration.md.erb
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/http/client"
4
- require "async/http/endpoint"
5
- require "json"
6
- require "securerandom"
7
-
8
- module Space::Architect
9
- # HTTP client for the space-server conversations upload API, modeled on
10
- # JobsClient: Bearer auth, injectable async-http client. Unlike JobsClient,
11
- # a non-2xx response is returned to the caller (not raised) — SessionSync
12
- # needs to distinguish created/updated/failed per file without aborting
13
- # the whole run on one bad upload.
14
- class ConversationsClient
15
- def initialize(host, token, client: nil, op_resolver: nil)
16
- @host = host.chomp("/")
17
- @token = token
18
- @client = client
19
- @op_resolver = op_resolver || SessionSync.method(:resolve_token)
20
- @resolved_token = nil
21
- end
22
-
23
- # POST /conversations — multipart per the frozen wire contract:
24
- # conversation[source_file] (the file), conversation[session_id].
25
- # Returns {status:, conversation_id:, action:, errors:}.
26
- def upload(path:, session_id:)
27
- Sync do
28
- with_client do |c|
29
- boundary = "ArchitectBoundary#{SecureRandom.hex(8)}"
30
- body = build_multipart(boundary, path, session_id)
31
- response = c.post("/conversations", headers: headers(boundary), body: body)
32
- status = response.status
33
- parsed = begin
34
- JSON.parse(response.read || "{}")
35
- rescue JSON::ParserError
36
- {}
37
- end
38
- {status: status, conversation_id: parsed["conversation_id"], action: parsed["action"], errors: parsed["errors"]}
39
- end
40
- end
41
- end
42
-
43
- private
44
-
45
- def with_client
46
- if @client
47
- yield @client
48
- else
49
- Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
50
- end
51
- end
52
-
53
- def headers(boundary)
54
- [
55
- ["authorization", "Bearer #{resolved_token}"],
56
- ["content-type", "multipart/form-data; boundary=#{boundary}"]
57
- ]
58
- end
59
-
60
- # Resolved once per client instance (per run), per spec.
61
- def resolved_token
62
- @resolved_token ||= @token.start_with?("op://") ? @op_resolver.call(@token) : @token
63
- end
64
-
65
- def build_multipart(boundary, path, session_id)
66
- crlf = "\r\n"
67
- filename = File.basename(path)
68
- file_content = File.binread(path)
69
- +"--#{boundary}#{crlf}" \
70
- "Content-Disposition: form-data; name=\"conversation[source_file]\"; filename=\"#{filename}\"#{crlf}" \
71
- "Content-Type: application/octet-stream#{crlf}" \
72
- "#{crlf}" \
73
- "#{file_content}#{crlf}" \
74
- "--#{boundary}#{crlf}" \
75
- "Content-Disposition: form-data; name=\"conversation[session_id]\"#{crlf}" \
76
- "#{crlf}" \
77
- "#{session_id}#{crlf}" \
78
- "--#{boundary}--#{crlf}"
79
- end
80
- end
81
- end
@@ -1,147 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/http/client"
4
- require "async/http/endpoint"
5
- require "json"
6
-
7
- module Space::Architect
8
- # HTTP client for the space-server jobs API, modeled on RunCreator: Bearer
9
- # auth, injectable async-http client, Space::Core::Error on any failure.
10
- class JobsClient
11
- # Poll bound for #wait_for_run_id: 2s * 30 attempts = 60s. Both are
12
- # injectable kwargs so tests need not sleep real seconds.
13
- POLL_INTERVAL_SECONDS = 2
14
- POLL_MAX_ATTEMPTS = 30
15
-
16
- def initialize(host, token, client: nil)
17
- @host = host.chomp("/")
18
- @token = token
19
- @client = client
20
- end
21
-
22
- # GET /jobs — returns the "jobs" array (owner-scoped, newest-first).
23
- def list
24
- get_json("/jobs")["jobs"]
25
- end
26
-
27
- # GET /jobs/:id — returns the job JSON.
28
- def show(id)
29
- get_json("/jobs/#{id}")
30
- end
31
-
32
- # POST /jobs — submits a job spec (harness/prompt/environment) and returns the
33
- # created job's integer id. Raises Space::Core::Error on any failure, modeled on
34
- # RunCreator#create (same 20x-status + integer-id checks).
35
- def create(spec)
36
- Sync do
37
- with_client do |c|
38
- response = c.post("/jobs", headers: headers, body: JSON.generate(spec))
39
- parsed = parse_json(response, "POST /jobs", expected_status: 201)
40
- id = parsed["id"]
41
- raise Space::Core::Error, "POST /jobs: missing or non-integer id in response: #{parsed.inspect[0, 200]}" \
42
- unless id.is_a?(Integer)
43
-
44
- id
45
- end
46
- end
47
- end
48
-
49
- # POST /jobs/:id/cancel — returns {"id"=>.., "status"=>"canceled"}.
50
- def cancel(id)
51
- Sync do
52
- with_client do |c|
53
- response = c.post("/jobs/#{id}/cancel", headers: headers, body: nil)
54
- parse_json(response, "POST /jobs/#{id}/cancel")
55
- end
56
- end
57
- end
58
-
59
- # GET /runs/:id/stream — yields each SSE event's data: payload to the
60
- # block as it arrives. Returns when the stream reports run_complete or
61
- # closes cleanly. Raises Space::Core::Error on a non-200 response.
62
- def stream(run_id, &block)
63
- Sync do
64
- with_client do |c|
65
- response = c.get("/runs/#{run_id}/stream", headers: headers)
66
- unless response.status == 200
67
- body = response.read || ""
68
- raise Space::Core::Error, "GET /runs/#{run_id}/stream failed (#{response.status}): #{body[0, 200]}"
69
- end
70
-
71
- read_sse(response, &block)
72
- end
73
- end
74
- end
75
-
76
- # Polls GET /jobs/:id until run_id is present, waiting `interval` seconds
77
- # between attempts, up to `attempts` tries. Raises Space::Core::Error if
78
- # the bound is exceeded.
79
- def wait_for_run_id(id, interval: POLL_INTERVAL_SECONDS, attempts: POLL_MAX_ATTEMPTS)
80
- attempts.times do |i|
81
- run_id = show(id)["run_id"]
82
- return run_id if run_id
83
- sleep interval unless i == attempts - 1
84
- end
85
- raise Space::Core::Error, "job #{id}: no run_id after #{attempts} attempts (#{attempts * interval}s)"
86
- end
87
-
88
- private
89
-
90
- def get_json(path)
91
- Sync do
92
- with_client do |c|
93
- response = c.get(path, headers: headers)
94
- parse_json(response, "GET #{path}")
95
- end
96
- end
97
- end
98
-
99
- def with_client
100
- if @client
101
- yield @client
102
- else
103
- Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
104
- end
105
- end
106
-
107
- def headers
108
- [
109
- ["authorization", "Bearer #{@token}"],
110
- ["content-type", "application/json"]
111
- ]
112
- end
113
-
114
- def parse_json(response, what, expected_status: 200)
115
- status = response.status
116
- body = response.read || ""
117
- raise Space::Core::Error, "#{what} failed (#{status}): #{body[0, 200]}" unless status == expected_status
118
-
119
- JSON.parse(body)
120
- end
121
-
122
- # Buffers chunks and splits on the SSE event terminator ("\n\n"). Each
123
- # event's data: line(s) are joined and yielded; an event whose data JSON
124
- # carries type == "run_complete" ends the stream.
125
- def read_sse(response)
126
- buffer = +""
127
- response.each do |chunk|
128
- buffer << chunk
129
- while (boundary = buffer.index("\n\n"))
130
- event = buffer.slice!(0..boundary + 1)
131
- data = event.each_line.select { |line| line.start_with?("data:") }
132
- .map { |line| line.sub(/\Adata:\s?/, "").chomp }
133
- .join("\n")
134
- next if data.empty?
135
-
136
- yield data
137
- parsed = begin
138
- JSON.parse(data)
139
- rescue JSON::ParserError
140
- nil
141
- end
142
- return if parsed.is_a?(Hash) && parsed["type"] == "run_complete"
143
- end
144
- end
145
- end
146
- end
147
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/http/client"
4
- require "async/http/endpoint"
5
- require "json"
6
-
7
- module Space::Architect
8
- class RunCreator
9
- def initialize(host, token, client: nil)
10
- @host = host.chomp("/")
11
- @token = token
12
- @client = client
13
- end
14
-
15
- # POSTs to /runs and returns the integer run id.
16
- # Raises Space::Core::Error on any failure — never returns nil.
17
- def create
18
- Sync do
19
- if @client
20
- response = @client.post("/runs", headers: headers, body: nil)
21
- parse_response(response)
22
- else
23
- Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) do |c|
24
- response = c.post("/runs", headers: headers, body: nil)
25
- parse_response(response)
26
- end
27
- end
28
- end
29
- end
30
-
31
- private
32
-
33
- def headers
34
- [
35
- ["authorization", "Bearer #{@token}"],
36
- ["content-type", "application/json"]
37
- ]
38
- end
39
-
40
- def parse_response(response)
41
- status = response.status
42
- body = response.read || ""
43
- raise Space::Core::Error, "POST /runs failed (#{status}): #{body[0, 200]}" unless status == 201
44
-
45
- parsed = JSON.parse(body)
46
- id = parsed["id"]
47
- raise Space::Core::Error, "POST /runs: missing or non-integer id in response: #{body[0, 200]}" \
48
- unless id.is_a?(Integer)
49
-
50
- id
51
- end
52
- end
53
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
-
5
- module Space::Architect
6
- module SessionSync
7
- # Resolves the absolute path to the `architect` executable the plist
8
- # should invoke, imitating RepoTender::CLI::Daemon::Helpers::Resolve's
9
- # detect_bin_path precedence: an env override (tests), the dev checkout's
10
- # exe/architect, `which architect`, then the installed gem's bin.
11
- module BinPath
12
- module_function
13
-
14
- def detect(env: ENV)
15
- override = env["SPACE_ARCHITECT_BIN_PATH"]
16
- return override if override && !override.empty?
17
-
18
- dev = File.expand_path("../../../exe/architect", __dir__)
19
- return dev if File.exist?(dev)
20
-
21
- out, _err, status = Open3.capture3("which", "architect")
22
- return out.strip if status.success? && !out.strip.empty?
23
-
24
- Gem.bin_path("space-architect", "architect")
25
- end
26
- end
27
- end
28
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "fileutils"
5
-
6
- module Space::Architect
7
- module SessionSync
8
- # Machine-managed cursor at $XDG_STATE_HOME/space-architect/session-sync.yaml
9
- # (imitates RepoTender::State::Store's load/write/emit shape). Keyed by
10
- # absolute source path; each entry records the size + mtime observed the
11
- # last time that file was uploaded.
12
- class Cursor
13
- Entry = Data.define(:size, :mtime) do
14
- def initialize(size:, mtime:) = super
15
- end
16
-
17
- def self.load(path)
18
- raw = read_yaml(path)
19
- raw.each_with_object({}) do |(k, v), acc|
20
- acc[k] = Entry.new(size: v["size"], mtime: v["mtime"])
21
- end
22
- end
23
-
24
- def self.write(path, entries)
25
- FileUtils.mkdir_p(File.dirname(path))
26
- payload = entries.each_with_object({}) { |(k, v), acc| acc[k] = {"size" => v.size, "mtime" => v.mtime} }
27
- tmp = "#{path}.tmp.#{Process.pid}"
28
- begin
29
- File.write(tmp, YAML.dump(payload, line_width: -1))
30
- File.rename(tmp, path)
31
- ensure
32
- File.delete(tmp) if File.exist?(tmp)
33
- end
34
- end
35
-
36
- def self.read_yaml(path)
37
- return {} unless File.exist?(path)
38
-
39
- YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
40
- end
41
- end
42
- end
43
- end
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Space::Architect
4
- module SessionSync
5
- # Hand-rolled launchd plist emitter for the session-sync agent, imitating
6
- # RepoTender::Launchd::Plist's shape. Under launchd's bare PATH, a bare
7
- # env-relative ruby binstub shebang resolves to macOS system Ruby instead
8
- # of the caller's toolchain, so ProgramArguments names the interpreter
9
- # explicitly (ruby_bin) rather than relying on shebang resolution — no
10
- # mise wrapping needed, since the sync itself pins no toolchain at run
11
- # time.
12
- class Plist
13
- DEFAULT_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
14
-
15
- HEADER = <<~XML
16
- <?xml version="1.0" encoding="UTF-8"?>
17
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
18
- <plist version="1.0">
19
- <dict>
20
- XML
21
- FOOTER = "</dict>\n</plist>\n"
22
-
23
- class << self
24
- # @param label [String] The job label (reverse-DNS; on-disk plist basename).
25
- # @param refresh_interval [Integer] StartInterval in seconds (must be > 0).
26
- # @param log_dir [String] Absolute directory for stdout/stderr logs.
27
- # @param bin_path [String] Absolute path to the architect bin script.
28
- # @param ruby_bin [String] Absolute path to the ruby to run bin_path under.
29
- # @param host [String] --host value passed to `sessions sync`.
30
- # @param env [Hash] EnvironmentVariables dict; must contain a non-empty
31
- # SessionSync::TOKEN_ENV value (resolved by the caller —
32
- # never resolved here). PATH defaults to DEFAULT_PATH
33
- # unless the caller supplies its own.
34
- # @return [String] the full plist XML.
35
- def call(label:, refresh_interval:, log_dir:, bin_path:, ruby_bin:, host:, env:)
36
- raise ArgumentError, "label is required" if label.to_s.empty?
37
- raise ArgumentError, "refresh_interval must be > 0" unless refresh_interval.is_a?(Integer) && refresh_interval > 0
38
- %w[log_dir bin_path ruby_bin].each do |k|
39
- v = binding.local_variable_get(k)
40
- raise ArgumentError, "#{k} must be absolute (got #{v.inspect})" unless v.is_a?(String) && File.absolute_path?(v)
41
- end
42
- raise ArgumentError, "host is required" if host.to_s.empty?
43
- raise ArgumentError, "env[#{SessionSync::TOKEN_ENV}] is required" if env.to_h[SessionSync::TOKEN_ENV].to_s.empty?
44
-
45
- out_log = File.join(log_dir, "#{label}.out.log")
46
- err_log = File.join(log_dir, "#{label}.err.log")
47
-
48
- body = +""
49
- body << key("Label") << string(label) << "\n"
50
- body << key("ProgramArguments") << "\n" << array([
51
- ruby_bin,
52
- bin_path,
53
- "sessions",
54
- "sync",
55
- "--host", host
56
- ])
57
- body << key("EnvironmentVariables") << "\n" << dict({"PATH" => DEFAULT_PATH}.merge(env.to_h))
58
- body << key("StartInterval") << integer(refresh_interval) << "\n"
59
- body << key("RunAtLoad") << boolean(true) << "\n"
60
- body << key("ProcessType") << string("Background") << "\n"
61
- body << key("StandardOutPath") << string(out_log) << "\n"
62
- body << key("StandardErrorPath") << string(err_log) << "\n"
63
-
64
- HEADER + body + FOOTER
65
- end
66
-
67
- private
68
-
69
- def key(name)
70
- " <key>#{escape(name)}</key>\n"
71
- end
72
-
73
- def escape(s)
74
- s.to_s
75
- .gsub("&", "&amp;")
76
- .gsub("<", "&lt;")
77
- .gsub(">", "&gt;")
78
- .gsub("\"", "&quot;")
79
- .gsub("'", "&apos;")
80
- end
81
-
82
- def string(s)
83
- " <string>#{escape(s)}</string>"
84
- end
85
-
86
- def integer(i)
87
- " <integer>#{Integer(i)}</integer>"
88
- end
89
-
90
- def boolean(b)
91
- " <#{b ? "true" : "false"}/>"
92
- end
93
-
94
- def array(items)
95
- out = +" <array>\n"
96
- items.each { |arg| out << " <string>#{escape(arg)}</string>\n" }
97
- out << " </array>"
98
- end
99
-
100
- def dict(hash)
101
- out = +" <dict>\n"
102
- hash.each do |k, v|
103
- out << " <key>#{escape(k)}</key>\n"
104
- out << " <string>#{escape(v)}</string>\n"
105
- end
106
- out << " </dict>"
107
- end
108
- end
109
- end
110
- end
111
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "cursor"
4
- require_relative "session_id"
5
-
6
- module Space::Architect
7
- module SessionSync
8
- # One sync pass: scans the pi + claude session roots, decides
9
- # upload/skip per the frozen cursor rules, uploads via the injected
10
- # client, and persists the updated cursor (unless dry_run).
11
- class Runner
12
- RECENT_MTIME_SECONDS = 60
13
-
14
- def initialize(client:, state_path:, pi_root:, claude_root:, now: -> { Time.now }, dry_run: false)
15
- @client = client
16
- @state_path = state_path
17
- @pi_root = pi_root
18
- @claude_root = claude_root
19
- @now = now
20
- @dry_run = dry_run
21
- end
22
-
23
- def call
24
- cursor = Cursor.load(@state_path)
25
- results = files.map { |path, session_id| process(path, session_id, cursor) }
26
- Cursor.write(@state_path, cursor) unless @dry_run
27
- results
28
- end
29
-
30
- private
31
-
32
- def process(path, session_id, cursor)
33
- stat = File.stat(path)
34
- recorded = cursor[path]
35
-
36
- return {path: path, session_id: session_id, action: :skipped, reason: "recent mtime"} if recent_mtime?(stat)
37
- return {path: path, session_id: session_id, action: :skipped, reason: "unchanged"} if recorded && recorded.size >= stat.size
38
-
39
- return {path: path, session_id: session_id, action: :would_upload} if @dry_run
40
-
41
- response = @client.upload(path: path, session_id: session_id)
42
- if [200, 201].include?(response[:status])
43
- cursor[path] = Cursor::Entry.new(size: stat.size, mtime: stat.mtime.to_i)
44
- {path: path, session_id: session_id, action: response[:status] == 201 ? :uploaded : :updated,
45
- conversation_id: response[:conversation_id]}
46
- else
47
- {path: path, session_id: session_id, action: :failed, status: response[:status], errors: response[:errors]}
48
- end
49
- end
50
-
51
- def recent_mtime?(stat)
52
- (@now.call - stat.mtime) < RECENT_MTIME_SECONDS
53
- end
54
-
55
- def files
56
- pi = Space::Core::Paths.content_tree(@pi_root).select { |p| p.end_with?(".jsonl") }
57
- .map { |p| [p, SessionId.for_pi(p)] }
58
- claude = Space::Core::Paths.content_tree(@claude_root).select { |p| p.end_with?(".jsonl") }
59
- .map { |p| [p, SessionId.for_claude(p)] }
60
- pi + claude
61
- end
62
- end
63
- end
64
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Space::Architect
4
- module SessionSync
5
- # Frozen session-id derivation rules for the two store shapes:
6
- # pi: <mangled-cwd>/<timestamp>_<sessionId>.jsonl -> part after the last "_"
7
- # claude: <mangled-cwd>/<sessionId>.jsonl -> basename minus ".jsonl"
8
- module SessionId
9
- def self.for_pi(path)
10
- base = File.basename(path, ".jsonl")
11
- idx = base.rindex("_")
12
- idx ? base[(idx + 1)..] : base
13
- end
14
-
15
- def self.for_claude(path)
16
- File.basename(path, ".jsonl")
17
- end
18
- end
19
- end
20
- end