harnex 0.2.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/GUIDE.md +242 -0
  3. data/LICENSE +21 -0
  4. data/README.md +119 -0
  5. data/TECHNICAL.md +595 -0
  6. data/bin/harnex +18 -0
  7. data/lib/harnex/adapters/base.rb +134 -0
  8. data/lib/harnex/adapters/claude.rb +105 -0
  9. data/lib/harnex/adapters/codex.rb +112 -0
  10. data/lib/harnex/adapters/generic.rb +14 -0
  11. data/lib/harnex/adapters.rb +32 -0
  12. data/lib/harnex/cli.rb +115 -0
  13. data/lib/harnex/commands/guide.rb +23 -0
  14. data/lib/harnex/commands/logs.rb +184 -0
  15. data/lib/harnex/commands/pane.rb +251 -0
  16. data/lib/harnex/commands/recipes.rb +104 -0
  17. data/lib/harnex/commands/run.rb +384 -0
  18. data/lib/harnex/commands/send.rb +415 -0
  19. data/lib/harnex/commands/skills.rb +163 -0
  20. data/lib/harnex/commands/status.rb +171 -0
  21. data/lib/harnex/commands/stop.rb +127 -0
  22. data/lib/harnex/commands/wait.rb +165 -0
  23. data/lib/harnex/core.rb +286 -0
  24. data/lib/harnex/runtime/api_server.rb +187 -0
  25. data/lib/harnex/runtime/file_change_hook.rb +111 -0
  26. data/lib/harnex/runtime/inbox.rb +207 -0
  27. data/lib/harnex/runtime/message.rb +23 -0
  28. data/lib/harnex/runtime/session.rb +380 -0
  29. data/lib/harnex/runtime/session_state.rb +55 -0
  30. data/lib/harnex/version.rb +3 -0
  31. data/lib/harnex/watcher/inotify.rb +43 -0
  32. data/lib/harnex/watcher/polling.rb +92 -0
  33. data/lib/harnex/watcher.rb +24 -0
  34. data/lib/harnex.rb +25 -0
  35. data/recipes/01_fire_and_watch.md +82 -0
  36. data/recipes/02_chain_implement.md +115 -0
  37. data/skills/chain-implement/SKILL.md +234 -0
  38. data/skills/close/SKILL.md +47 -0
  39. data/skills/dispatch/SKILL.md +171 -0
  40. data/skills/harnex/SKILL.md +304 -0
  41. data/skills/open/SKILL.md +32 -0
  42. metadata +88 -0
@@ -0,0 +1,184 @@
1
+ require "optparse"
2
+
3
+ module Harnex
4
+ class Logs
5
+ DEFAULT_LINES = 200
6
+ POLL_INTERVAL = 0.1
7
+ READ_CHUNK_SIZE = 4096
8
+
9
+ def self.usage(program_name = "harnex logs")
10
+ <<~TEXT
11
+ Usage: #{program_name} [options]
12
+
13
+ Options:
14
+ --id ID Session ID to inspect (required)
15
+ --repo PATH Resolve using PATH's repo root (default: current repo)
16
+ --cli CLI Filter the active session by CLI
17
+ --follow Keep streaming appended output until session exit
18
+ --lines N Print the last N lines before following (default: #{DEFAULT_LINES})
19
+ -h, --help Show this help
20
+ TEXT
21
+ end
22
+
23
+ def initialize(argv)
24
+ @argv = argv.dup
25
+ @options = {
26
+ id: nil,
27
+ repo_path: Dir.pwd,
28
+ cli: nil,
29
+ follow: false,
30
+ lines: DEFAULT_LINES,
31
+ help: false
32
+ }
33
+ end
34
+
35
+ def run
36
+ parser.parse!(@argv)
37
+ if @options[:help]
38
+ puts self.class.usage
39
+ return 0
40
+ end
41
+
42
+ raise "--id is required for harnex logs" unless @options[:id]
43
+ raise OptionParser::InvalidArgument, "--lines must be >= 0" if @options[:lines].negative?
44
+
45
+ target = resolve_target
46
+ return 1 unless target
47
+
48
+ offset = print_snapshot(target.fetch(:path))
49
+ return 0 unless @options[:follow] && target[:live]
50
+
51
+ follow(target.fetch(:path), offset, target.fetch(:pid))
52
+ 0
53
+ end
54
+
55
+ private
56
+
57
+ def parser
58
+ @parser ||= OptionParser.new do |opts|
59
+ opts.banner = "Usage: harnex logs [options]"
60
+ opts.on("--id ID", "Session ID to inspect") { |value| @options[:id] = Harnex.normalize_id(value) }
61
+ opts.on("--repo PATH", "Resolve using PATH's repo root") { |value| @options[:repo_path] = value }
62
+ opts.on("--cli CLI", "Filter the active session by CLI") { |value| @options[:cli] = value }
63
+ opts.on("--follow", "Keep streaming appended output until session exit") { @options[:follow] = true }
64
+ opts.on("--lines N", Integer, "Print the last N lines before following") { |value| @options[:lines] = value }
65
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
66
+ end
67
+ end
68
+
69
+ def resolve_target
70
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
71
+ session = Harnex.read_registry(repo_root, @options[:id], cli: @options[:cli])
72
+
73
+ if session
74
+ path = session["output_log_path"].to_s
75
+ path = Harnex.output_log_path(repo_root, @options[:id]) if path.empty?
76
+ return log_target(path, live: true, pid: session["pid"], repo_root: repo_root)
77
+ end
78
+
79
+ if cli_filter_mismatch?(repo_root)
80
+ warn("harnex logs: no active session found with id #{@options[:id].inspect} and cli #{@options[:cli].inspect}")
81
+ return nil
82
+ end
83
+
84
+ log_target(Harnex.output_log_path(repo_root, @options[:id]), live: false, pid: nil, repo_root: repo_root)
85
+ end
86
+
87
+ def cli_filter_mismatch?(repo_root)
88
+ return false unless @options[:cli]
89
+
90
+ session = Harnex.read_registry(repo_root, @options[:id])
91
+ return false unless session
92
+
93
+ Harnex.cli_key(Harnex.session_cli(session)) != Harnex.cli_key(@options[:cli])
94
+ end
95
+
96
+ def log_target(path, live:, pid:, repo_root:)
97
+ return { path: path, live: live, pid: pid, repo_root: repo_root } if File.file?(path)
98
+
99
+ if live
100
+ warn("harnex logs: transcript not found at #{path}")
101
+ else
102
+ warn("harnex logs: no session or transcript found with id #{@options[:id].inspect} for #{repo_root}")
103
+ end
104
+ nil
105
+ end
106
+
107
+ def print_snapshot(path)
108
+ data, offset = snapshot_data(path, @options[:lines])
109
+ $stdout.write(data)
110
+ $stdout.flush
111
+ offset
112
+ end
113
+
114
+ def snapshot_data(path, line_limit)
115
+ size = File.size?(path).to_i
116
+ return ["".b, size] if size.zero? || line_limit.zero?
117
+
118
+ offset = size
119
+ buffer = +"".b
120
+
121
+ File.open(path, "rb") do |file|
122
+ while offset.positive?
123
+ chunk_size = [READ_CHUNK_SIZE, offset].min
124
+ offset -= chunk_size
125
+ file.seek(offset)
126
+ chunk = file.read(chunk_size)
127
+ next if chunk.nil? || chunk.empty?
128
+
129
+ buffer = chunk.b + buffer
130
+ break if buffer.count("\n") >= line_limit
131
+ end
132
+ end
133
+
134
+ lines = buffer.lines
135
+ data =
136
+ if lines.length > line_limit
137
+ lines.last(line_limit).join
138
+ else
139
+ buffer
140
+ end
141
+ [data, size]
142
+ end
143
+
144
+ def follow(path, offset, pid)
145
+ current_offset = offset
146
+
147
+ loop do
148
+ current_offset = stream_growth(path, current_offset)
149
+ unless Harnex.alive_pid?(pid)
150
+ drain_growth(path, current_offset)
151
+ return
152
+ end
153
+
154
+ sleep POLL_INTERVAL
155
+ end
156
+ end
157
+
158
+ def drain_growth(path, offset)
159
+ current_offset = offset
160
+
161
+ loop do
162
+ next_offset = stream_growth(path, current_offset)
163
+ return next_offset if next_offset == current_offset
164
+
165
+ current_offset = next_offset
166
+ end
167
+ end
168
+
169
+ def stream_growth(path, offset)
170
+ size = File.size?(path).to_i
171
+ offset = size if offset > size
172
+ return offset if size == offset
173
+
174
+ File.open(path, "rb") do |file|
175
+ file.seek(offset)
176
+ while (chunk = file.read(READ_CHUNK_SIZE))
177
+ $stdout.write(chunk)
178
+ end
179
+ end
180
+ $stdout.flush
181
+ size
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,251 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "open3"
4
+ require "time"
5
+
6
+ module Harnex
7
+ class Pane
8
+ FOLLOW_INTERVAL = 1.0
9
+
10
+ def self.usage(program_name = "harnex pane")
11
+ <<~TEXT
12
+ Usage: #{program_name} [options]
13
+
14
+ Options:
15
+ --id ID Session ID to inspect (required)
16
+ --repo PATH Resolve using PATH's repo root (default: current repo)
17
+ --cli CLI Filter the active session by CLI
18
+ --lines N Capture the last N lines instead of the full pane
19
+ --follow Refresh the pane snapshot every second until the session exits
20
+ --interval N Refresh interval in seconds for --follow (default: #{FOLLOW_INTERVAL.to_i})
21
+ --json Output JSON with capture metadata
22
+ -h, --help Show this help
23
+ TEXT
24
+ end
25
+
26
+ def initialize(argv)
27
+ @argv = argv.dup
28
+ @options = {
29
+ id: nil,
30
+ repo_path: Dir.pwd,
31
+ repo_explicit: false,
32
+ cli: nil,
33
+ lines: nil,
34
+ follow: false,
35
+ interval: FOLLOW_INTERVAL,
36
+ json: false,
37
+ help: false
38
+ }
39
+ end
40
+
41
+ def run
42
+ parser.parse!(@argv)
43
+ if @options[:help]
44
+ puts self.class.usage
45
+ return 0
46
+ end
47
+
48
+ raise "--id is required for harnex pane" unless @options[:id]
49
+ if @options[:lines] && @options[:lines] < 1
50
+ raise OptionParser::InvalidArgument, "--lines must be >= 1"
51
+ end
52
+
53
+ session = resolve_session
54
+ return 1 unless session
55
+
56
+ return 1 unless tmux_available?
57
+
58
+ target = resolve_tmux_target(session)
59
+ return 1 unless target
60
+ return 1 unless tmux_target_exists?(session, target)
61
+
62
+ if @options[:follow]
63
+ follow(session, target)
64
+ else
65
+ text = capture(target)
66
+ return 1 unless text
67
+
68
+ emit_output(session.fetch("id"), text)
69
+ end
70
+ 0
71
+ end
72
+
73
+ private
74
+
75
+ def parser
76
+ @parser ||= OptionParser.new do |opts|
77
+ opts.banner = "Usage: harnex pane [options]"
78
+ opts.on("--id ID", "Session ID to inspect") { |value| @options[:id] = Harnex.normalize_id(value) }
79
+ opts.on("--repo PATH", "Resolve using PATH's repo root") do |value|
80
+ @options[:repo_path] = value
81
+ @options[:repo_explicit] = true
82
+ end
83
+ opts.on("--cli CLI", "Filter the active session by CLI") { |value| @options[:cli] = value }
84
+ opts.on("--lines N", Integer, "Capture the last N lines instead of the full pane") { |value| @options[:lines] = value }
85
+ opts.on("--follow", "Refresh the pane snapshot until the session exits") { @options[:follow] = true }
86
+ opts.on("--interval N", Float, "Refresh interval in seconds for --follow") { |value| @options[:interval] = value }
87
+ opts.on("--json", "Output JSON with capture metadata") { @options[:json] = true }
88
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
89
+ end
90
+ end
91
+
92
+ def resolve_session
93
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
94
+ session = Harnex.read_registry(repo_root, @options[:id], cli: @options[:cli])
95
+ return session if session
96
+
97
+ unless @options[:repo_explicit]
98
+ candidates = Harnex.active_sessions(nil, id: @options[:id], cli: @options[:cli])
99
+ return candidates.first if candidates.length == 1
100
+
101
+ if candidates.length > 1
102
+ repos = candidates.map { |candidate| candidate["repo_root"].to_s }.uniq.sort
103
+ warn("harnex pane: multiple active sessions found with id #{@options[:id].inspect}; use --repo to disambiguate: #{repos.join(', ')}")
104
+ return nil
105
+ end
106
+ end
107
+
108
+ if cli_filter_mismatch?(repo_root)
109
+ warn("harnex pane: no active session found with id #{@options[:id].inspect} and cli #{@options[:cli].inspect}")
110
+ else
111
+ warn("harnex pane: no active session found with id #{@options[:id].inspect} for #{repo_root}")
112
+ end
113
+ nil
114
+ end
115
+
116
+ def cli_filter_mismatch?(repo_root)
117
+ return false unless @options[:cli]
118
+
119
+ session = Harnex.read_registry(repo_root, @options[:id])
120
+ return false unless session
121
+
122
+ Harnex.cli_key(Harnex.session_cli(session)) != Harnex.cli_key(@options[:cli])
123
+ end
124
+
125
+ def resolve_tmux_target(session)
126
+ target = session["tmux_target"].to_s.strip
127
+ return target unless target.empty?
128
+
129
+ discovery = Harnex.tmux_pane_for_pid(session["pid"])
130
+ if discovery
131
+ persist_tmux_metadata(session, discovery)
132
+ return discovery.fetch(:target)
133
+ end
134
+
135
+ tmux_session = session["tmux_session"].to_s.strip
136
+ tmux_window = session["tmux_window"].to_s.strip
137
+ unless tmux_session.empty? || tmux_window.empty?
138
+ return "#{tmux_session}:#{tmux_window}"
139
+ end
140
+
141
+ warn("harnex pane: session #{session.fetch('id').inspect} is not tmux-backed or the tmux pane could not be located")
142
+ nil
143
+ end
144
+
145
+ def persist_tmux_metadata(session, discovery)
146
+ session["tmux_target"] = discovery.fetch(:target)
147
+ session["tmux_session"] = discovery.fetch(:session_name)
148
+ session["tmux_window"] = discovery.fetch(:window_name)
149
+
150
+ path = session["registry_path"].to_s
151
+ return if path.empty? || !File.exist?(path)
152
+
153
+ payload = JSON.parse(File.read(path))
154
+ payload["tmux_target"] = session["tmux_target"]
155
+ payload["tmux_session"] = session["tmux_session"]
156
+ payload["tmux_window"] = session["tmux_window"]
157
+ Harnex.write_registry(path, payload)
158
+ rescue JSON::ParserError
159
+ nil
160
+ end
161
+
162
+ def tmux_available?
163
+ return true if system("tmux", "-V", out: File::NULL, err: File::NULL)
164
+
165
+ warn("harnex pane: tmux is not installed or not available in PATH")
166
+ false
167
+ rescue Errno::ENOENT
168
+ warn("harnex pane: tmux is not installed or not available in PATH")
169
+ false
170
+ end
171
+
172
+ def tmux_target_exists?(session, target)
173
+ return true if system("tmux", "has-session", "-t", target, out: File::NULL, err: File::NULL)
174
+
175
+ warn("harnex pane: session #{session.fetch('id').inspect} is not tmux-backed or the tmux target #{target.inspect} no longer exists")
176
+ false
177
+ rescue Errno::ENOENT
178
+ warn("harnex pane: tmux is not installed or not available in PATH")
179
+ false
180
+ end
181
+
182
+ def follow(session, target)
183
+ pid = session["pid"].to_i
184
+ last_text = nil
185
+
186
+ loop do
187
+ text = capture(target)
188
+ break unless text
189
+
190
+ if text != last_text
191
+ clear_screen
192
+ if @options[:json]
193
+ emit_output(session.fetch("id"), text)
194
+ else
195
+ $stdout.write(text)
196
+ $stdout.flush
197
+ end
198
+ last_text = text
199
+ end
200
+
201
+ break unless Harnex.alive_pid?(pid)
202
+
203
+ sleep @options[:interval]
204
+ end
205
+ end
206
+
207
+ def clear_screen
208
+ $stdout.write("\e[H\e[2J")
209
+ $stdout.flush
210
+ end
211
+
212
+ def capture(target)
213
+ stdout, stderr, status = capture_command(capture_command_args(target))
214
+ return stdout if status.success?
215
+
216
+ message = stderr.to_s.strip
217
+ message = "tmux capture-pane failed" if message.empty?
218
+ warn("harnex pane: #{message}")
219
+ nil
220
+ rescue Errno::ENOENT
221
+ warn("harnex pane: tmux is not installed or not available in PATH")
222
+ nil
223
+ end
224
+
225
+ def capture_command_args(target)
226
+ command = ["tmux", "capture-pane", "-t", target, "-p"]
227
+ command += ["-S", "-#{@options[:lines]}"] if @options[:lines]
228
+ command
229
+ end
230
+
231
+ def capture_command(command)
232
+ Open3.capture3(*command)
233
+ end
234
+
235
+ def emit_output(id, text)
236
+ if @options[:json]
237
+ puts JSON.generate({
238
+ ok: true,
239
+ id: id,
240
+ captured_at: Time.now.iso8601,
241
+ lines: @options[:lines],
242
+ text: text
243
+ })
244
+ return
245
+ end
246
+
247
+ $stdout.write(text)
248
+ $stdout.flush
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,104 @@
1
+ require "optparse"
2
+
3
+ module Harnex
4
+ class Recipes
5
+ RECIPES_DIR = File.expand_path("../../../../recipes", __FILE__)
6
+
7
+ def self.usage
8
+ <<~TEXT
9
+ Usage: harnex recipes [list|show <name>]
10
+
11
+ Subcommands:
12
+ list List available recipes (default)
13
+ show <name> Print a recipe by name or number
14
+
15
+ Examples:
16
+ harnex recipes
17
+ harnex recipes list
18
+ harnex recipes show 01
19
+ harnex recipes show fire_and_watch
20
+ TEXT
21
+ end
22
+
23
+ def initialize(argv)
24
+ @argv = argv.dup
25
+ end
26
+
27
+ def run
28
+ subcommand = @argv.shift
29
+ case subcommand
30
+ when nil, "list"
31
+ list_recipes
32
+ when "show"
33
+ show_recipe(@argv.first)
34
+ when "-h", "--help"
35
+ puts self.class.usage
36
+ 0
37
+ else
38
+ # Treat bare arg as show
39
+ show_recipe(subcommand)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def list_recipes
46
+ files = recipe_files
47
+ if files.empty?
48
+ puts "No recipes found."
49
+ return 0
50
+ end
51
+
52
+ puts "Recipes:\n\n"
53
+ files.each do |file|
54
+ name = File.basename(file, ".md")
55
+ title = extract_title(file)
56
+ puts " #{name} #{title}"
57
+ end
58
+ puts "\nRun `harnex recipes show <name>` to read one."
59
+ 0
60
+ end
61
+
62
+ def show_recipe(query)
63
+ unless query
64
+ warn("harnex recipes show: recipe name required")
65
+ return 1
66
+ end
67
+
68
+ file = find_recipe(query)
69
+ unless file
70
+ warn("harnex recipes: no recipe matching #{query.inspect}")
71
+ warn("Run `harnex recipes list` to see available recipes.")
72
+ return 1
73
+ end
74
+
75
+ puts File.read(file)
76
+ 0
77
+ end
78
+
79
+ def find_recipe(query)
80
+ files = recipe_files
81
+ # Exact basename match (with or without .md)
82
+ exact = files.find { |f| File.basename(f, ".md") == query }
83
+ return exact if exact
84
+
85
+ # Prefix match (e.g. "01" matches "01_fire_and_watch")
86
+ prefix = files.find { |f| File.basename(f, ".md").start_with?(query) }
87
+ return prefix if prefix
88
+
89
+ # Substring match (e.g. "fire" matches "01_fire_and_watch")
90
+ files.find { |f| File.basename(f, ".md").include?(query) }
91
+ end
92
+
93
+ def recipe_files
94
+ return [] unless Dir.exist?(RECIPES_DIR)
95
+
96
+ Dir.glob(File.join(RECIPES_DIR, "*.md")).sort
97
+ end
98
+
99
+ def extract_title(file)
100
+ first_line = File.foreach(file).first.to_s.strip
101
+ first_line.start_with?("#") ? first_line.sub(/^#+\s*/, "") : ""
102
+ end
103
+ end
104
+ end