codex-notify 1.0.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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +50 -0
  3. data/LICENSE +21 -0
  4. data/README.md +782 -0
  5. data/bin/codex-notify +6 -0
  6. data/bin/codex-notify-hook +6 -0
  7. data/lib/codex_notify/cli.rb +112 -0
  8. data/lib/codex_notify/config.rb +113 -0
  9. data/lib/codex_notify/config_diagnostics.rb +61 -0
  10. data/lib/codex_notify/config_migrator.rb +148 -0
  11. data/lib/codex_notify/config_support.rb +157 -0
  12. data/lib/codex_notify/destination_name.rb +18 -0
  13. data/lib/codex_notify/destination_resolver.rb +68 -0
  14. data/lib/codex_notify/durable_slack_publisher.rb +70 -0
  15. data/lib/codex_notify/env_source_loader.rb +91 -0
  16. data/lib/codex_notify/hook_cli.rb +90 -0
  17. data/lib/codex_notify/hook_config.rb +110 -0
  18. data/lib/codex_notify/hook_event.rb +16 -0
  19. data/lib/codex_notify/hook_formatter.rb +61 -0
  20. data/lib/codex_notify/hook_input_validator.rb +241 -0
  21. data/lib/codex_notify/hook_runner.rb +181 -0
  22. data/lib/codex_notify/hook_store.rb +133 -0
  23. data/lib/codex_notify/hook_thread_publisher.rb +107 -0
  24. data/lib/codex_notify/log_event_parser.rb +230 -0
  25. data/lib/codex_notify/message_formatter.rb +115 -0
  26. data/lib/codex_notify/outbox_commands.rb +43 -0
  27. data/lib/codex_notify/secret_protection.rb +37 -0
  28. data/lib/codex_notify/session_log.rb +63 -0
  29. data/lib/codex_notify/slack_client.rb +117 -0
  30. data/lib/codex_notify/slack_delivery_worker.rb +269 -0
  31. data/lib/codex_notify/slack_outbox.rb +206 -0
  32. data/lib/codex_notify/stream_processor.rb +141 -0
  33. data/lib/codex_notify/trusted_config_loader.rb +159 -0
  34. data/lib/codex_notify/version.rb +5 -0
  35. data/lib/codex_notify.rb +36 -0
  36. metadata +96 -0
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'pathname'
5
+ require 'securerandom'
6
+ require 'time'
7
+ require_relative 'secret_protection'
8
+
9
+ module CodexNotify
10
+ class SlackOutbox
11
+ VERSION = 1
12
+ MAX_JOBS = 10_000
13
+ MAX_BYTES = 64 * 1024 * 1024
14
+
15
+ class Error < StandardError; end
16
+ class CapacityError < Error; end
17
+
18
+ def initialize(path, clock: -> { Time.now.utc })
19
+ @path = Pathname(path)
20
+ @clock = clock
21
+ end
22
+
23
+ attr_reader :path
24
+
25
+ def enqueue(channel:, ordering_key:, generation:, action:, chunks:, recovery_chunks: [])
26
+ with_state_lock do
27
+ enforce_capacity!
28
+ sequence = allocate_sequence
29
+ job = {
30
+ 'version' => VERSION,
31
+ 'id' => SecureRandom.uuid,
32
+ 'sequence' => sequence,
33
+ 'channel' => channel.to_s,
34
+ 'ordering_key' => ordering_key.to_s,
35
+ 'generation' => generation.to_i,
36
+ 'action' => action.to_s,
37
+ 'message_chunks' => redact_chunks(chunks),
38
+ 'recovery_root_chunks' => redact_chunks(recovery_chunks),
39
+ 'phase' => 'pending',
40
+ 'next_chunk' => 0,
41
+ 'resolved_thread_ts' => nil,
42
+ 'attempt_count' => 0,
43
+ 'ambiguous_attempt_count' => 0,
44
+ 'next_attempt_at' => nil,
45
+ 'last_error' => nil,
46
+ 'created_at' => now.iso8601,
47
+ 'updated_at' => now.iso8601
48
+ }
49
+ write_job(job)
50
+ job['id']
51
+ end
52
+ end
53
+
54
+ def pending_root?(ordering_key, generation:)
55
+ jobs(:pending).any? do |job|
56
+ job['ordering_key'] == ordering_key.to_s && job['generation'] == generation.to_i &&
57
+ %w[ensure_thread root_or_reply].include?(job['action'])
58
+ end
59
+ end
60
+
61
+ def jobs(status = :pending)
62
+ directory(status).glob('*.json').map { |file| read_job(file) }.sort_by { |job| job['sequence'] }
63
+ end
64
+
65
+ def update(job)
66
+ job['updated_at'] = now.iso8601
67
+ with_state_lock { write_job(job) }
68
+ end
69
+
70
+ def complete(job)
71
+ with_state_lock { job_path(job, :pending).delete if job_path(job, :pending).exist? }
72
+ end
73
+
74
+ def move(job, status)
75
+ with_state_lock do
76
+ source = job_path(job, :pending)
77
+ target = job_path(job, status)
78
+ target.dirname.mkpath
79
+ File.chmod(0o700, target.dirname)
80
+ File.rename(source, target) if source.exist?
81
+ end
82
+ end
83
+
84
+ def try_drain_lock
85
+ prepare_directories
86
+ File.open(@path.join('locks/drain.lock'), File::RDWR | File::CREAT, 0o600) do |file|
87
+ return false unless file.flock(File::LOCK_EX | File::LOCK_NB)
88
+
89
+ yield
90
+ true
91
+ ensure
92
+ file.flock(File::LOCK_UN) rescue nil
93
+ end
94
+ end
95
+
96
+ def status_rows
97
+ %i[pending needs_review failed].flat_map do |status|
98
+ jobs(status).map do |job|
99
+ {
100
+ status: status.to_s.tr('_', '-'), id: job['id'], sequence: job['sequence'],
101
+ created_at: job['created_at'], error: job.dig('last_error', 'code')
102
+ }
103
+ end
104
+ end
105
+ end
106
+
107
+ def retry(id)
108
+ with_state_lock do
109
+ source = %i[needs_review failed].map { |status| directory(status).join("#{id}.json") }.find(&:exist?)
110
+ raise Error, "outbox job not found: #{id}" unless source
111
+
112
+ job = read_job(source)
113
+ job['next_attempt_at'] = nil
114
+ job['last_error'] = nil
115
+ job['ambiguous_attempt_count'] = 0
116
+ source.delete
117
+ write_job(job)
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def now = @clock.call.utc
124
+
125
+ def prepare_directories
126
+ @path.mkpath
127
+ File.chmod(0o700, @path)
128
+ %i[pending needs_review failed].each do |status|
129
+ directory(status).mkpath
130
+ File.chmod(0o700, directory(status))
131
+ end
132
+ @path.join('locks').mkpath
133
+ File.chmod(0o700, @path.join('locks'))
134
+ end
135
+
136
+ def directory(status)
137
+ @path.join(status.to_s.tr('_', '-'))
138
+ end
139
+
140
+ def with_state_lock
141
+ prepare_directories
142
+ File.open(@path.join('locks/state.lock'), File::RDWR | File::CREAT, 0o600) do |file|
143
+ raise Error, 'could not lock outbox state' unless file.flock(File::LOCK_EX)
144
+
145
+ cleanup_temp_files
146
+ yield
147
+ end
148
+ end
149
+
150
+ def cleanup_temp_files
151
+ [@path, *%i[pending needs_review failed].map { |status| directory(status) }].each do |dir|
152
+ dir.glob('.*.tmp-*').each { |file| file.delete if file.file? }
153
+ end
154
+ end
155
+
156
+ def allocate_sequence
157
+ index = @path.join('index.json')
158
+ value = index.exist? ? JSON.parse(index.read).fetch('next_sequence', 1).to_i : 1
159
+ atomic_write(index, JSON.generate('next_sequence' => value + 1))
160
+ value
161
+ rescue JSON::ParserError
162
+ raise Error, 'outbox index is invalid'
163
+ end
164
+
165
+ def enforce_capacity!
166
+ files = %i[pending needs_review failed].flat_map { |status| directory(status).glob('*.json') }
167
+ bytes = files.sum { |file| file.size }
168
+ raise CapacityError, "outbox capacity reached (#{files.size} jobs, #{bytes} bytes)" if files.size >= MAX_JOBS || bytes >= MAX_BYTES
169
+ end
170
+
171
+ def redact_chunks(chunks)
172
+ Array(chunks).map { |chunk| SecretProtection.redact(chunk.to_s) }
173
+ end
174
+
175
+ def write_job(job)
176
+ validate_job!(job)
177
+ atomic_write(job_path(job, :pending), JSON.generate(job))
178
+ end
179
+
180
+ def read_job(file)
181
+ job = JSON.parse(file.read)
182
+ validate_job!(job)
183
+ job
184
+ rescue JSON::ParserError
185
+ raise Error, "outbox job is invalid: #{file.basename}"
186
+ end
187
+
188
+ def validate_job!(job)
189
+ required = %w[version id sequence channel ordering_key generation action message_chunks phase next_chunk]
190
+ raise Error, 'outbox job is invalid' unless job.is_a?(Hash) && job['version'] == VERSION && required.all? { |key| job.key?(key) }
191
+ end
192
+
193
+ def job_path(job, status)
194
+ directory(status).join("#{job.fetch('id')}.json")
195
+ end
196
+
197
+ def atomic_write(path, content)
198
+ path.dirname.mkpath
199
+ tmp = path.dirname.join(".#{path.basename}.tmp-#{Process.pid}-#{SecureRandom.hex(4)}")
200
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(content) }
201
+ File.rename(tmp, path)
202
+ ensure
203
+ tmp.delete if defined?(tmp) && tmp.exist?
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module CodexNotify
6
+ module StreamProcessor
7
+ module_function
8
+
9
+ def process_codex_log_stream(stream, token:, channel:, root_message:, initial_prompt: nil, user_name: 'user', include_tools: false,
10
+ throttle_sec: 0.0, post_func:, sleep_func: Kernel.method(:sleep), publisher: nil)
11
+ if publisher
12
+ return process_durable_stream(
13
+ stream, root_message:, initial_prompt:, user_name:, include_tools:, publisher:
14
+ )
15
+ end
16
+
17
+ last_sent_fingerprint = nil
18
+ thread_ts_by_session = {}
19
+
20
+ post_message = lambda do |message, thread_ts|
21
+ CodexNotify::MessageFormatter.chunks(message).each do |part|
22
+ post_func.call(token, channel, part, thread_ts)
23
+ sleep_func.call(throttle_sec)
24
+ end
25
+ end
26
+
27
+ post_root = lambda do |message|
28
+ parts = CodexNotify::MessageFormatter.chunks(message).to_a
29
+ response = post_func.call(token, channel, parts.shift, nil)
30
+ thread_ts = response.fetch('ts').to_s
31
+ sleep_func.call(throttle_sec)
32
+ parts.each do |part|
33
+ post_func.call(token, channel, part, thread_ts)
34
+ sleep_func.call(throttle_sec)
35
+ end
36
+ thread_ts
37
+ end
38
+
39
+ post_root.call(root_message)
40
+
41
+ unless initial_prompt.nil? || initial_prompt.empty?
42
+ message = CodexNotify::MessageFormatter.message(title: user_name, body: initial_prompt, presentation: :plain)
43
+ thread_ts_by_session['__default__'] = post_root.call(message)
44
+ end
45
+
46
+ stream.each do |raw|
47
+ line = raw.strip
48
+ next if line.empty?
49
+
50
+ event = JSON.parse(line)
51
+ session_id = CodexNotify::LogEventParser.extract_session_id(event) || '__default__'
52
+ extracted_events = CodexNotify::LogEventParser.extract_events(event)
53
+ next if extracted_events.empty?
54
+
55
+ extracted_events.each do |kind, text, part_type|
56
+ next if text.empty?
57
+ next if part_type == 'tool' && !include_tools
58
+
59
+ fingerprint = "#{session_id}:#{part_type}:#{kind}:#{text[0, 240]}"
60
+ next if fingerprint == last_sent_fingerprint
61
+
62
+ last_sent_fingerprint = fingerprint
63
+ title = kind == 'assistant' ? 'assistant' : kind
64
+ thread_ts = thread_ts_by_session[session_id]
65
+ presentation = %w[user assistant system].include?(title) ? :plain : :block
66
+ message_title = kind == 'user' ? user_name : title
67
+ message = CodexNotify::MessageFormatter.message(title: message_title, body: text, presentation:)
68
+
69
+ if kind == 'user'
70
+ if thread_ts
71
+ post_message.call(message, thread_ts)
72
+ else
73
+ thread_ts = post_root.call(message)
74
+ thread_ts_by_session[session_id] = thread_ts
75
+ end
76
+ next
77
+ end
78
+
79
+ post_message.call(message, thread_ts)
80
+ end
81
+ rescue JSON::ParserError
82
+ next
83
+ end
84
+
85
+ 0
86
+ end
87
+
88
+ def process_durable_stream(stream, root_message:, initial_prompt:, user_name:, include_tools:, publisher:)
89
+ last_sent_fingerprint = nil
90
+ monitor_key = publisher.key('log-monitor', Process.pid)
91
+ publisher.publish_standalone(key: monitor_key, message: root_message)
92
+ return 1 unless drain_succeeded?(publisher)
93
+
94
+ unless initial_prompt.nil? || initial_prompt.empty?
95
+ message = CodexNotify::MessageFormatter.message(title: user_name, body: initial_prompt, presentation: :plain)
96
+ publisher.publish_root_or_reply(key: publisher.key('log', '__default__'), message:)
97
+ return 1 unless drain_succeeded?(publisher)
98
+ end
99
+
100
+ stream.each do |raw|
101
+ line = raw.strip
102
+ next if line.empty?
103
+
104
+ event = JSON.parse(line)
105
+ session_id = CodexNotify::LogEventParser.extract_session_id(event) || '__default__'
106
+ extracted_events = CodexNotify::LogEventParser.extract_events(event)
107
+ next if extracted_events.empty?
108
+
109
+ extracted_events.each do |kind, body, part_type|
110
+ next if body.empty?
111
+ next if part_type == 'tool' && !include_tools
112
+
113
+ fingerprint = "#{session_id}:#{part_type}:#{kind}:#{body[0, 240]}"
114
+ next if fingerprint == last_sent_fingerprint
115
+
116
+ last_sent_fingerprint = fingerprint
117
+ title = kind == 'assistant' ? 'assistant' : kind
118
+ presentation = %w[user assistant system].include?(title) ? :plain : :block
119
+ message_title = kind == 'user' ? user_name : title
120
+ message = CodexNotify::MessageFormatter.message(title: message_title, body:, presentation:)
121
+ key = publisher.key('log', session_id)
122
+ if kind == 'user'
123
+ publisher.publish_root_or_reply(key:, message:)
124
+ else
125
+ publisher.publish_reply(key:, message:, recovery_root_message: message)
126
+ end
127
+ return 1 unless drain_succeeded?(publisher)
128
+ end
129
+ rescue JSON::ParserError
130
+ next
131
+ end
132
+
133
+ 0
134
+ end
135
+
136
+ def drain_succeeded?(publisher)
137
+ result = publisher.drain
138
+ result.failed.empty? && result.needs_review.empty?
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'yaml'
5
+ require_relative 'config_diagnostics'
6
+ require_relative 'destination_name'
7
+
8
+ module CodexNotify
9
+ class TrustedConfigLoader
10
+ DEFAULT_RELATIVE_PATH = Pathname('codex-notify/config.yml')
11
+ TOP_LEVEL_KEYS = %w[env_policy default_destination destinations].freeze
12
+ DESTINATION_KEYS = %w[token channel].freeze
13
+ ENV_POLICIES = %w[legacy restricted].freeze
14
+
15
+ class Error < StandardError; end
16
+
17
+ ConfigFile = Data.define(:kind, :path, :values)
18
+
19
+ def initialize(environment: ENV, home: nil, stderr: $stderr)
20
+ @environment = environment
21
+ @home = home
22
+ @stderr = stderr
23
+ end
24
+
25
+ def load(explicit_path: nil)
26
+ files = []
27
+ explicit = explicit_path && Pathname(explicit_path).expand_path
28
+ files << load_file(explicit, kind: :config_explicit, required: true) if explicit
29
+
30
+ default = default_path
31
+ files << load_file(default, kind: :config, required: false) unless explicit == default
32
+ files.compact
33
+ end
34
+
35
+ def default_path
36
+ xdg_home = environment['XDG_CONFIG_HOME']
37
+ if xdg_home && !xdg_home.empty?
38
+ base = Pathname(xdg_home)
39
+ raise Error, 'XDG_CONFIG_HOME must be an absolute path' unless base.absolute?
40
+
41
+ return base.join(DEFAULT_RELATIVE_PATH)
42
+ end
43
+
44
+ home_path = home || environment['HOME']
45
+ home_path = Dir.home if !home_path || home_path.empty?
46
+ Pathname(home_path).expand_path.join('.config', DEFAULT_RELATIVE_PATH)
47
+ rescue ArgumentError, SystemCallError => e
48
+ raise Error, "could not resolve the home configuration directory: #{e.class}"
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :environment, :home
54
+
55
+ def load_file(path, kind:, required:)
56
+ unless path.exist?
57
+ raise Error, "config file does not exist: #{path}" if required
58
+
59
+ return nil
60
+ end
61
+ raise Error, "config path is not a file: #{path}" unless path.file?
62
+
63
+ ConfigDiagnostics.warn_if_file_insecure(path, label: 'config file', stderr: @stderr)
64
+ document = YAML.safe_load_file(
65
+ path.to_s,
66
+ permitted_classes: [],
67
+ permitted_symbols: [],
68
+ aliases: false
69
+ )
70
+ ConfigFile.new(kind:, path:, values: validate(document))
71
+ rescue Psych::Exception
72
+ raise Error, "config file is not valid safe YAML: #{path}"
73
+ rescue SystemCallError => e
74
+ raise Error, "could not read config file #{path}: #{e.class}"
75
+ end
76
+
77
+ def validate(document)
78
+ mapping = require_mapping(document, 'configuration')
79
+ reject_unknown_keys(mapping, TOP_LEVEL_KEYS, 'configuration')
80
+
81
+ values = {}
82
+ apply_policy(mapping, values)
83
+ apply_default_destination(mapping, values)
84
+ apply_destinations(mapping, values)
85
+ values.freeze
86
+ end
87
+
88
+ def apply_policy(mapping, values)
89
+ return unless mapping.key?('env_policy')
90
+
91
+ policy = require_string(mapping['env_policy'], 'env_policy').strip.downcase
92
+ unless ENV_POLICIES.include?(policy)
93
+ raise Error, "env_policy must be one of: #{ENV_POLICIES.join(', ')}"
94
+ end
95
+
96
+ values['CODEX_NOTIFY_ENV_POLICY'] = policy
97
+ end
98
+
99
+ def apply_default_destination(mapping, values)
100
+ return unless mapping.key?('default_destination')
101
+
102
+ destination = require_mapping(mapping['default_destination'], 'default_destination')
103
+ reject_unknown_keys(destination, DESTINATION_KEYS, 'default_destination')
104
+ raise Error, 'default_destination must define token or channel' if destination.empty?
105
+
106
+ assign_string(destination, 'token', values, 'SLACK_BOT_TOKEN', context: 'default_destination')
107
+ assign_string(destination, 'channel', values, 'SLACK_CHANNEL', context: 'default_destination')
108
+ end
109
+
110
+ def apply_destinations(mapping, values)
111
+ return unless mapping.key?('destinations')
112
+
113
+ destinations = require_mapping(mapping['destinations'], 'destinations')
114
+ normalized_names = {}
115
+ destinations.each do |raw_name, raw_destination|
116
+ raise Error, 'destination names must be strings' unless raw_name.is_a?(String)
117
+
118
+ name = DestinationName.normalize(raw_name)
119
+ raise Error, "destination name is duplicated after normalization: #{name}" if normalized_names.key?(name)
120
+
121
+ normalized_names[name] = true
122
+ destination = require_mapping(raw_destination, "destination #{name}")
123
+ reject_unknown_keys(destination, DESTINATION_KEYS, "destination #{name}")
124
+ raise Error, "destination #{name} must define channel" unless destination.key?('channel')
125
+
126
+ assign_string(destination, 'token', values, "SLACK_BOT_TOKEN__#{name}", context: "destination #{name}")
127
+ assign_string(destination, 'channel', values, "SLACK_CHANNEL__#{name}", context: "destination #{name}")
128
+ end
129
+ rescue DestinationName::Error => e
130
+ raise Error, e.message
131
+ end
132
+
133
+ def assign_string(mapping, key, values, output_key, context:)
134
+ return unless mapping.key?(key)
135
+
136
+ values[output_key] = require_string(mapping[key], "#{context}.#{key}")
137
+ end
138
+
139
+ def require_mapping(value, label)
140
+ raise Error, "#{label} must be a mapping" unless value.is_a?(Hash)
141
+ raise Error, "#{label} keys must be strings" unless value.keys.all? { |key| key.is_a?(String) }
142
+
143
+ value
144
+ end
145
+
146
+ def require_string(value, label)
147
+ raise Error, "#{label} must be a non-empty string" unless value.is_a?(String) && !value.strip.empty?
148
+
149
+ value
150
+ end
151
+
152
+ def reject_unknown_keys(mapping, allowed, label)
153
+ unknown = mapping.keys - allowed
154
+ return if unknown.empty?
155
+
156
+ raise Error, "#{label} contains unknown keys"
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodexNotify
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'codex_notify/version'
4
+ require_relative 'codex_notify/log_event_parser'
5
+ require_relative 'codex_notify/message_formatter'
6
+ require_relative 'codex_notify/session_log'
7
+ require_relative 'codex_notify/stream_processor'
8
+ require_relative 'codex_notify/secret_protection'
9
+ require_relative 'codex_notify/config_diagnostics'
10
+ require_relative 'codex_notify/config_support'
11
+ require_relative 'codex_notify/destination_name'
12
+ require_relative 'codex_notify/trusted_config_loader'
13
+ require_relative 'codex_notify/config_migrator'
14
+ require_relative 'codex_notify/config'
15
+ require_relative 'codex_notify/cli'
16
+ require_relative 'codex_notify/slack_client'
17
+ require_relative 'codex_notify/slack_outbox'
18
+ require_relative 'codex_notify/slack_delivery_worker'
19
+ require_relative 'codex_notify/durable_slack_publisher'
20
+ require_relative 'codex_notify/outbox_commands'
21
+ require_relative 'codex_notify/env_source_loader'
22
+ require_relative 'codex_notify/destination_resolver'
23
+ require_relative 'codex_notify/hook_config'
24
+ require_relative 'codex_notify/hook_event'
25
+ require_relative 'codex_notify/hook_input_validator'
26
+ require_relative 'codex_notify/hook_store'
27
+ require_relative 'codex_notify/hook_formatter'
28
+ require_relative 'codex_notify/hook_thread_publisher'
29
+ require_relative 'codex_notify/hook_runner'
30
+ require_relative 'codex_notify/hook_cli'
31
+
32
+ module CodexNotify
33
+ def self.main(*args, **kwargs)
34
+ CLI.main(*args, **kwargs)
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: codex-notify
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Koichiro Ohba
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dotenv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.2'
26
+ description: Log-tail and Codex Hook commands that send compact Codex activity notifications
27
+ to Slack.
28
+ email:
29
+ - koichiro.ohba@gmail.com
30
+ executables:
31
+ - codex-notify
32
+ - codex-notify-hook
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - CHANGELOG.md
37
+ - LICENSE
38
+ - README.md
39
+ - bin/codex-notify
40
+ - bin/codex-notify-hook
41
+ - lib/codex_notify.rb
42
+ - lib/codex_notify/cli.rb
43
+ - lib/codex_notify/config.rb
44
+ - lib/codex_notify/config_diagnostics.rb
45
+ - lib/codex_notify/config_migrator.rb
46
+ - lib/codex_notify/config_support.rb
47
+ - lib/codex_notify/destination_name.rb
48
+ - lib/codex_notify/destination_resolver.rb
49
+ - lib/codex_notify/durable_slack_publisher.rb
50
+ - lib/codex_notify/env_source_loader.rb
51
+ - lib/codex_notify/hook_cli.rb
52
+ - lib/codex_notify/hook_config.rb
53
+ - lib/codex_notify/hook_event.rb
54
+ - lib/codex_notify/hook_formatter.rb
55
+ - lib/codex_notify/hook_input_validator.rb
56
+ - lib/codex_notify/hook_runner.rb
57
+ - lib/codex_notify/hook_store.rb
58
+ - lib/codex_notify/hook_thread_publisher.rb
59
+ - lib/codex_notify/log_event_parser.rb
60
+ - lib/codex_notify/message_formatter.rb
61
+ - lib/codex_notify/outbox_commands.rb
62
+ - lib/codex_notify/secret_protection.rb
63
+ - lib/codex_notify/session_log.rb
64
+ - lib/codex_notify/slack_client.rb
65
+ - lib/codex_notify/slack_delivery_worker.rb
66
+ - lib/codex_notify/slack_outbox.rb
67
+ - lib/codex_notify/stream_processor.rb
68
+ - lib/codex_notify/trusted_config_loader.rb
69
+ - lib/codex_notify/version.rb
70
+ homepage: https://github.com/koichiro/codex-notify
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ source_code_uri: https://github.com/koichiro/codex-notify/tree/v1.0.0
75
+ bug_tracker_uri: https://github.com/koichiro/codex-notify/issues
76
+ changelog_uri: https://github.com/koichiro/codex-notify/blob/v1.0.0/CHANGELOG.md
77
+ documentation_uri: https://github.com/koichiro/codex-notify#readme
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.4.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.16
94
+ specification_version: 4
95
+ summary: Send compact Codex activity notifications to Slack
96
+ test_files: []