harnex 0.3.4 → 0.5.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.
@@ -6,10 +6,13 @@ module Harnex
6
6
  class Runner
7
7
  DEFAULT_TIMEOUT = 5.0
8
8
  KNOWN_FLAGS = %w[
9
- --id --description --detach --tmux --host --port --watch --context --timeout --inbox-ttl --help
9
+ --id --description --detach --tmux --host --port --watch --watch-file
10
+ --stall-after --max-resumes --preset --context --meta --summary-out
11
+ --timeout --inbox-ttl --help
10
12
  ].freeze
11
13
  VALUE_FLAGS = %w[
12
- --id --description --host --port --watch --context --timeout --inbox-ttl
14
+ --id --description --host --port --watch --watch-file --stall-after
15
+ --max-resumes --preset --context --meta --summary-out --timeout --inbox-ttl
13
16
  ].freeze
14
17
 
15
18
  def self.usage(program_name = "harnex run")
@@ -23,13 +26,22 @@ module Harnex
23
26
  --tmux [NAME] Run in a tmux window (implies --detach)
24
27
  --host HOST Bind host for the local API (default: #{DEFAULT_HOST})
25
28
  --port PORT Force a specific local API port
26
- --watch PATH Auto-send a file-change hook on modification
29
+ --watch Enable blocking babysitter mode (foreground only)
30
+ --stall-after DUR Force-resume threshold (default: #{RunWatcher::DEFAULT_STALL_AFTER_S.to_i}s)
31
+ --max-resumes N Max forced resumes before escalation (default: #{RunWatcher::DEFAULT_MAX_RESUMES})
32
+ --preset NAME Watch preset: impl, plan, gate (requires --watch)
33
+ --watch-file PATH Auto-send a file-change hook on modification
27
34
  --context TEXT Inject as the initial prompt (prepends session header)
35
+ --meta JSON Attach parsed JSON metadata to the started event
36
+ --summary-out PATH Append dispatch telemetry summary JSONL to PATH
28
37
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
29
38
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
30
39
  -h, --help Show this help
31
40
 
32
41
  Notes:
42
+ Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
43
+ Bare `--watch` enables the babysitter.
44
+ Explicit --stall-after/--max-resumes values override --preset defaults.
33
45
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
34
46
  Any other CLI name is launched with generic wrapping.
35
47
  Wrapper options may appear before or after <cli>.
@@ -43,8 +55,16 @@ module Harnex
43
55
  description: nil,
44
56
  host: DEFAULT_HOST,
45
57
  port: nil,
58
+ watch_enabled: false,
59
+ stall_after_s: RunWatcher::DEFAULT_STALL_AFTER_S,
60
+ stall_after_explicit: false,
61
+ max_resumes: RunWatcher::DEFAULT_MAX_RESUMES,
62
+ max_resumes_explicit: false,
63
+ preset: nil,
46
64
  watch: nil,
47
65
  context: nil,
66
+ meta: nil,
67
+ summary_out: nil,
48
68
  detach: false,
49
69
  tmux: false,
50
70
  tmux_name: nil,
@@ -64,13 +84,18 @@ module Harnex
64
84
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
65
85
 
66
86
  repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
87
+ @options[:summary_out] = resolve_summary_out(repo_root)
67
88
  @options[:id] ||= Harnex.generate_id(repo_root)
68
89
  validate_unique_id!(repo_root)
69
90
  effective_child_args = apply_context(child_args)
70
91
  adapter = Harnex.build_adapter(cli_name, effective_child_args)
71
92
  @options[:detach] = true if @options[:tmux]
93
+ validate_watch_mode!
94
+ resolve_watch_preset!
72
95
 
73
- if @options[:detach]
96
+ if @options[:watch_enabled]
97
+ run_watch_mode(adapter, repo_root)
98
+ elsif @options[:detach]
74
99
  run_detached(adapter, cli_name, child_args, repo_root)
75
100
  else
76
101
  run_foreground(adapter, repo_root)
@@ -90,10 +115,25 @@ module Harnex
90
115
  if @options[:tmux]
91
116
  run_in_tmux(cli_name, child_args, repo_root)
92
117
  else
93
- run_headless(adapter, repo_root)
118
+ result = run_headless(adapter, repo_root)
119
+ result[:exit_code]
94
120
  end
95
121
  end
96
122
 
123
+ def run_watch_mode(adapter, repo_root)
124
+ Session.validate_binary!(adapter.build_command)
125
+
126
+ result = run_headless(adapter, repo_root, emit_payload: false)
127
+ return result[:exit_code] unless result[:ok]
128
+
129
+ RunWatcher.new(
130
+ id: @options[:id],
131
+ repo_root: repo_root,
132
+ stall_after_s: @options[:stall_after_s],
133
+ max_resumes: @options[:max_resumes]
134
+ ).run
135
+ end
136
+
97
137
  def run_in_tmux(cli_name, child_args, repo_root)
98
138
  harnex_bin = File.expand_path("../../../bin/harnex", __dir__)
99
139
  tmux_cmd = [harnex_bin, "run", cli_name]
@@ -101,8 +141,10 @@ module Harnex
101
141
  tmux_cmd += ["--description", @options[:description]] if @options[:description]
102
142
  tmux_cmd += ["--host", @options[:host]]
103
143
  tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
104
- tmux_cmd += ["--watch", @options[:watch]] if @options[:watch]
144
+ tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
105
145
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
146
+ tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
147
+ tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
106
148
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
107
149
  tmux_cmd += ["--"] + child_args unless child_args.empty?
108
150
 
@@ -137,7 +179,7 @@ module Harnex
137
179
  0
138
180
  end
139
181
 
140
- def run_headless(adapter, repo_root)
182
+ def run_headless(adapter, repo_root, emit_payload: true)
141
183
  log_dir = File.join(Harnex::STATE_DIR, "logs")
142
184
  FileUtils.mkdir_p(log_dir)
143
185
  log_path = File.join(log_dir, "#{@options[:id]}.log")
@@ -159,7 +201,7 @@ module Harnex
159
201
  Process.detach(child_pid)
160
202
 
161
203
  registry = wait_for_registration(repo_root)
162
- return registration_timeout(@options[:id]) unless registry
204
+ return { ok: false, exit_code: registration_timeout(@options[:id]) } unless registry
163
205
 
164
206
  payload = {
165
207
  ok: true,
@@ -172,12 +214,19 @@ module Harnex
172
214
  output_log_path: Harnex.output_log_path(repo_root, @options[:id])
173
215
  }
174
216
  payload[:description] = @options[:description] if @options[:description]
175
- puts JSON.generate(payload)
176
- 0
217
+ puts JSON.generate(payload) if emit_payload
218
+ { ok: true, exit_code: 0, registry: registry, payload: payload }
177
219
  end
178
220
 
179
221
  private
180
222
 
223
+ def validate_watch_mode!
224
+ return unless @options[:watch_enabled]
225
+ return unless @options[:detach]
226
+
227
+ raise OptionParser::InvalidOption, "--watch is only supported in foreground mode"
228
+ end
229
+
181
230
  def validate_unique_id!(repo_root)
182
231
  existing = Harnex.read_registry(repo_root, @options[:id])
183
232
  return unless existing
@@ -198,6 +247,8 @@ module Harnex
198
247
  id: @options[:id],
199
248
  watch: watch,
200
249
  description: @options[:description],
250
+ meta: @options[:meta],
251
+ summary_out: @options[:summary_out],
201
252
  inbox_ttl: @options[:inbox_ttl]
202
253
  )
203
254
  end
@@ -294,15 +345,66 @@ module Harnex
294
345
  when /\A--port=(.+)\z/
295
346
  @options[:port] = Integer(required_option_value("--port", Regexp.last_match(1)))
296
347
  when "--watch"
297
- index += 1
298
- @options[:watch] = required_option_value(arg, argv[index])
348
+ value = argv[index + 1]
349
+ if value.nil? || value == "--" || wrapper_option_token?(value)
350
+ @options[:watch_enabled] = true
351
+ else
352
+ index += 1
353
+ @options[:watch] = required_option_value(arg, argv[index])
354
+ end
299
355
  when /\A--watch=(.+)\z/
300
356
  @options[:watch] = required_option_value("--watch", Regexp.last_match(1))
357
+ when "--watch-file"
358
+ index += 1
359
+ @options[:watch] = required_option_value(arg, argv[index])
360
+ when /\A--watch-file=(.+)\z/
361
+ @options[:watch] = required_option_value("--watch-file", Regexp.last_match(1))
362
+ when "--stall-after"
363
+ index += 1
364
+ @options[:stall_after_s] = Harnex.parse_duration_seconds(
365
+ required_option_value(arg, argv[index]),
366
+ option_name: "--stall-after"
367
+ )
368
+ @options[:stall_after_explicit] = true
369
+ when /\A--stall-after=(.+)\z/
370
+ @options[:stall_after_s] = Harnex.parse_duration_seconds(
371
+ required_option_value("--stall-after", Regexp.last_match(1)),
372
+ option_name: "--stall-after"
373
+ )
374
+ @options[:stall_after_explicit] = true
375
+ when "--max-resumes"
376
+ index += 1
377
+ @options[:max_resumes] = parse_non_negative_integer(
378
+ required_option_value(arg, argv[index]),
379
+ option_name: "--max-resumes"
380
+ )
381
+ @options[:max_resumes_explicit] = true
382
+ when /\A--max-resumes=(.+)\z/
383
+ @options[:max_resumes] = parse_non_negative_integer(
384
+ required_option_value("--max-resumes", Regexp.last_match(1)),
385
+ option_name: "--max-resumes"
386
+ )
387
+ @options[:max_resumes_explicit] = true
388
+ when "--preset"
389
+ index += 1
390
+ @options[:preset] = required_option_value(arg, argv[index])
391
+ when /\A--preset=(.+)\z/
392
+ @options[:preset] = required_option_value("--preset", Regexp.last_match(1))
301
393
  when "--context"
302
394
  index += 1
303
395
  @options[:context] = required_option_value(arg, argv[index])
304
396
  when /\A--context=(.+)\z/
305
397
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
398
+ when "--meta"
399
+ index += 1
400
+ @options[:meta] = parse_meta(required_option_value(arg, argv[index]))
401
+ when /\A--meta=(.+)\z/
402
+ @options[:meta] = parse_meta(required_option_value("--meta", Regexp.last_match(1)))
403
+ when "--summary-out"
404
+ index += 1
405
+ @options[:summary_out] = required_option_value(arg, argv[index])
406
+ when /\A--summary-out=(.+)\z/
407
+ @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
306
408
  when "--timeout"
307
409
  index += 1
308
410
  @options[:timeout] = Float(required_option_value(arg, argv[index]))
@@ -358,7 +460,9 @@ module Harnex
358
460
  nil
359
461
  when *VALUE_FLAGS
360
462
  index += 1
361
- when /\A--(?:id|description|host|port|watch|context|timeout|inbox-ttl)=/
463
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|timeout|inbox-ttl)=/
464
+ nil
465
+ when /\A--preset=/
362
466
  nil
363
467
  else
364
468
  return true
@@ -372,7 +476,54 @@ module Harnex
372
476
  def wrapper_option_token?(arg)
373
477
  KNOWN_FLAGS.include?(arg) ||
374
478
  arg == "-h" ||
375
- arg.start_with?("--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--context=", "--timeout=", "--inbox-ttl=")
479
+ arg.start_with?(
480
+ "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
481
+ "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
482
+ "--timeout=", "--inbox-ttl="
483
+ )
484
+ end
485
+
486
+ def resolve_watch_preset!
487
+ preset_name = @options[:preset]
488
+ return if preset_name.nil?
489
+
490
+ unless @options[:watch_enabled]
491
+ raise "harnex run: --preset requires --watch"
492
+ end
493
+
494
+ preset = WatchPresets.fetch(preset_name)
495
+ unless preset
496
+ valid = WatchPresets.valid_names.join(", ")
497
+ raise "harnex run: unknown --preset #{preset_name.inspect} (valid: #{valid})"
498
+ end
499
+
500
+ @options[:stall_after_s] = preset[:stall_after_s] unless @options[:stall_after_explicit]
501
+ @options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
502
+ end
503
+
504
+ def parse_non_negative_integer(value, option_name:)
505
+ integer = Integer(value)
506
+ raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
507
+
508
+ integer
509
+ rescue ArgumentError
510
+ raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
511
+ end
512
+
513
+ def parse_meta(value)
514
+ parsed = JSON.parse(value)
515
+ return parsed if parsed.is_a?(Hash)
516
+
517
+ raise OptionParser::InvalidOption, "--meta must be a JSON object"
518
+ rescue JSON::ParserError => e
519
+ raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
520
+ end
521
+
522
+ def resolve_summary_out(repo_root)
523
+ configured = @options[:summary_out]
524
+ return Harnex.default_summary_out_path(repo_root) if configured.nil?
525
+
526
+ File.expand_path(configured, repo_root)
376
527
  end
377
528
 
378
529
  def default_inbox_ttl
@@ -98,7 +98,7 @@ module Harnex
98
98
  end
99
99
 
100
100
  def render_table(sessions)
101
- columns = ["ID", "CLI", "PID", "PORT", "AGE", "STATE", "REPO", "DESC"]
101
+ columns = ["ID", "CLI", "PID", "PORT", "AGE", "IDLE", "STATE", "REPO", "DESC"]
102
102
 
103
103
  rows = sessions.map { |session| table_row(session, columns) }
104
104
  widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
@@ -117,6 +117,7 @@ module Harnex
117
117
  "PID" => session["pid"].to_s,
118
118
  "PORT" => session["port"].to_s,
119
119
  "AGE" => timeago(session["started_at"]),
120
+ "IDLE" => format_idle(session["log_idle_s"]),
120
121
  "STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
121
122
  "DESC" => truncate(session["description"])
122
123
  }
@@ -133,7 +134,22 @@ module Harnex
133
134
 
134
135
  seconds = (Time.now - Time.parse(timestamp.to_s)).to_i
135
136
  seconds = 0 if seconds.negative?
137
+ compact_duration(seconds)
138
+ rescue StandardError
139
+ timestamp.to_s
140
+ end
141
+
142
+ def format_idle(idle_seconds)
143
+ return "-" if idle_seconds.nil?
144
+
145
+ seconds = Integer(idle_seconds)
146
+ seconds = 0 if seconds.negative?
147
+ compact_duration(seconds)
148
+ rescue StandardError
149
+ "-"
150
+ end
136
151
 
152
+ def compact_duration(seconds)
137
153
  case seconds
138
154
  when 0...60
139
155
  "#{seconds}s"
@@ -144,8 +160,6 @@ module Harnex
144
160
  else
145
161
  "#{seconds / 86_400}d"
146
162
  end
147
- rescue StandardError
148
- timestamp.to_s
149
163
  end
150
164
 
151
165
  def truncate(value)
@@ -0,0 +1,209 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Harnex
6
+ class RunWatcher
7
+ DEFAULT_STALL_AFTER_S = 8 * 60.0
8
+ DEFAULT_MAX_RESUMES = 1
9
+ POLL_INTERVAL_S = 60.0
10
+ MAX_STATUS_ERRORS = 3
11
+ RESUME_TEXT = "resume"
12
+
13
+ def initialize(
14
+ id:,
15
+ repo_root:,
16
+ stall_after_s: DEFAULT_STALL_AFTER_S,
17
+ max_resumes: DEFAULT_MAX_RESUMES,
18
+ poll_interval_s: POLL_INTERVAL_S,
19
+ sleeper: nil,
20
+ monotonic_clock: nil,
21
+ out: $stdout,
22
+ err: $stderr
23
+ )
24
+ @id = Harnex.normalize_id(id)
25
+ @repo_root = repo_root
26
+ @stall_after_s = Float(stall_after_s)
27
+ @max_resumes = Integer(max_resumes)
28
+ @poll_interval_s = Float(poll_interval_s)
29
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
30
+ @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
31
+ @out = out
32
+ @err = err
33
+ end
34
+
35
+ def run
36
+ polls = 0
37
+ resumes = 0
38
+ final_state = "unknown"
39
+ outcome = :error
40
+ status_errors = 0
41
+ start_at = now
42
+
43
+ @out.puts(
44
+ "harnex watch: id=#{@id} stall-after=#{format_duration(@stall_after_s)} " \
45
+ "max-resumes=#{@max_resumes} poll=#{format_duration(@poll_interval_s)}"
46
+ )
47
+
48
+ loop do
49
+ polls += 1
50
+ snapshot = fetch_snapshot
51
+
52
+ case snapshot[:kind]
53
+ when :exited
54
+ final_state = "exited"
55
+ outcome = :exited
56
+ @out.puts("harnex watch: session exited")
57
+ break
58
+ when :error
59
+ if snapshot[:fatal]
60
+ @err.puts("harnex watch: #{snapshot[:error]}")
61
+ outcome = :error
62
+ break
63
+ end
64
+
65
+ status_errors += 1
66
+ if status_errors >= MAX_STATUS_ERRORS
67
+ @err.puts("harnex watch: #{snapshot[:error]} (status retry limit reached)")
68
+ outcome = :error
69
+ break
70
+ end
71
+ when :status
72
+ status_errors = 0
73
+ final_state = snapshot[:agent_state]
74
+
75
+ if snapshot[:stalled]
76
+ if resumes < @max_resumes
77
+ send_resume(snapshot[:registry])
78
+ resumes += 1
79
+ @out.puts(
80
+ "harnex watch: resume #{resumes}/#{@max_resumes} " \
81
+ "(idle=#{format_duration(snapshot[:idle_seconds])}, state=#{final_state})"
82
+ )
83
+ else
84
+ outcome = :escalated
85
+ @out.puts("harnex watch: max resumes reached, escalating")
86
+ break
87
+ end
88
+ end
89
+ end
90
+
91
+ @sleeper.call(@poll_interval_s)
92
+ end
93
+
94
+ elapsed = (now - start_at).round(1)
95
+ @out.puts(
96
+ "harnex watch: summary id=#{@id} polls=#{polls} resumes=#{resumes} " \
97
+ "final_state=#{final_state} outcome=#{outcome} elapsed_s=#{elapsed}"
98
+ )
99
+ outcome_to_exit_code(outcome)
100
+ rescue StandardError => e
101
+ @err.puts("harnex watch: #{e.message}")
102
+ 1
103
+ end
104
+
105
+ private
106
+
107
+ def fetch_snapshot
108
+ registry = Harnex.read_registry(@repo_root, @id)
109
+ return { kind: :exited } unless registry
110
+
111
+ status = fetch_status(registry)
112
+ return status if status[:kind] == :error
113
+
114
+ payload = status[:payload]
115
+ unless payload.key?("log_idle_s")
116
+ return {
117
+ kind: :error,
118
+ fatal: true,
119
+ error: "status payload missing log_idle_s; upgrade to a Layer-1+ harnex build"
120
+ }
121
+ end
122
+
123
+ agent_state = payload["agent_state"].to_s.strip
124
+ return { kind: :exited } if agent_state == "exited"
125
+
126
+ idle_seconds = parse_idle_seconds(payload["log_idle_s"])
127
+ {
128
+ kind: :status,
129
+ registry: registry,
130
+ agent_state: agent_state.empty? ? "unknown" : agent_state,
131
+ idle_seconds: idle_seconds,
132
+ stalled: !idle_seconds.nil? && idle_seconds >= @stall_after_s
133
+ }
134
+ end
135
+
136
+ def fetch_status(registry)
137
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/status")
138
+ request = Net::HTTP::Get.new(uri)
139
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
140
+
141
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
142
+ http.request(request)
143
+ end
144
+
145
+ unless response.is_a?(Net::HTTPSuccess)
146
+ return { kind: :error, error: "status request failed with HTTP #{response.code} for session #{@id}" }
147
+ end
148
+
149
+ { kind: :status_payload, payload: JSON.parse(response.body) }
150
+ rescue StandardError => e
151
+ { kind: :error, error: "status request failed for session #{@id}: #{e.message}" }
152
+ end
153
+
154
+ def send_resume(registry)
155
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
156
+ request = Net::HTTP::Post.new(uri)
157
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
158
+ request["Content-Type"] = "application/json"
159
+ request.body = JSON.generate(
160
+ text: RESUME_TEXT,
161
+ submit: true,
162
+ enter_only: false,
163
+ force: true
164
+ )
165
+
166
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
167
+ http.request(request)
168
+ end
169
+
170
+ return if response.is_a?(Net::HTTPSuccess)
171
+
172
+ raise "resume send failed with HTTP #{response.code} for session #{@id}"
173
+ rescue StandardError => e
174
+ raise "resume send failed for session #{@id}: #{e.message}"
175
+ end
176
+
177
+ def parse_idle_seconds(value)
178
+ return nil if value.nil?
179
+
180
+ seconds = Integer(value)
181
+ seconds.negative? ? 0 : seconds
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ def outcome_to_exit_code(outcome)
187
+ case outcome
188
+ when :exited
189
+ 0
190
+ when :escalated
191
+ 2
192
+ else
193
+ 1
194
+ end
195
+ end
196
+
197
+ def format_duration(seconds)
198
+ value = seconds.to_f
199
+ return "#{value.round(1)}s" if value < 60
200
+ return "#{(value / 60).round(1)}m" if value < 3600
201
+
202
+ "#{(value / 3600).round(1)}h"
203
+ end
204
+
205
+ def now
206
+ @monotonic_clock.call
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,17 @@
1
+ module Harnex
2
+ module WatchPresets
3
+ TABLE = {
4
+ "impl" => { stall_after_s: 8 * 60.0, max_resumes: 1 }.freeze,
5
+ "plan" => { stall_after_s: 3 * 60.0, max_resumes: 2 }.freeze,
6
+ "gate" => { stall_after_s: 15 * 60.0, max_resumes: 0 }.freeze
7
+ }.freeze
8
+
9
+ def self.fetch(name)
10
+ TABLE[name]
11
+ end
12
+
13
+ def self.valid_names
14
+ TABLE.keys
15
+ end
16
+ end
17
+ end