harnex 0.8.0 → 0.10.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.
data/lib/harnex/core.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
+ require "open3"
3
4
  require "optparse"
4
5
  require "securerandom"
5
6
  require "set"
@@ -19,6 +20,8 @@ module Harnex
19
20
  DEFAULT_PORT_SPAN = Integer(env_value("HARNEX_PORT_SPAN", default: "4000"))
20
21
  DEFAULT_ID = "default"
21
22
  WATCH_DEBOUNCE_SECONDS = 1.0
23
+ GIT_FINGERPRINT_FULL_BYTES = 4 * 1024 * 1024
24
+ GIT_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
22
25
  STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
23
26
  SESSIONS_DIR = File.join(STATE_DIR, "sessions")
24
27
  WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
@@ -114,49 +117,204 @@ module Harnex
114
117
  text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
115
118
  end
116
119
 
117
- def default_summary_out_path(repo_root)
118
- root = repo_root.to_s
119
- return nil if root.empty?
120
-
121
- File.join(root, ".harnex", "dispatch.jsonl")
122
- end
123
-
124
- def git_capture_start(repo_root)
120
+ def git_capture_start(repo_root, exclude_paths: [])
125
121
  sha = git_output(repo_root, "rev-parse", "HEAD")
126
122
  branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
127
123
  return {} if sha.empty? || branch.empty?
128
124
 
125
+ excluded_paths = normalize_git_excluded_paths(exclude_paths)
126
+ worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
129
127
  {
130
128
  sha: sha,
131
- branch: branch
129
+ branch: branch,
130
+ worktree: worktree,
131
+ excluded_paths: excluded_paths
132
132
  }
133
133
  rescue StandardError
134
134
  {}
135
135
  end
136
136
 
137
- def git_capture_end(repo_root, start_sha)
138
- start_sha = start_sha.to_s.strip
137
+ # Captures committed and in-progress work relative to the state observed at
138
+ # session start. Passing the full git_capture_start hash avoids treating an
139
+ # unchanged pre-existing dirty tree as worker activity; a SHA string remains
140
+ # supported for older callers.
141
+ def git_capture_end(repo_root, start, exclude_paths: nil)
142
+ start_capture = start.is_a?(Hash) ? start : {}
143
+ start_sha = (start_capture[:sha] || start_capture["sha"] || start).to_s.strip
139
144
  return {} if start_sha.empty?
140
145
 
146
+ excluded_paths ||= start_capture[:excluded_paths] || start_capture["excluded_paths"] || []
147
+ excluded_paths = normalize_git_excluded_paths(excluded_paths)
141
148
  end_sha = git_output(repo_root, "rev-parse", "HEAD")
142
149
  range = "#{start_sha}..#{end_sha}"
143
- shortstat = git_output(repo_root, "diff", "--shortstat", range)
144
- changed_paths = git_output(repo_root, "diff", "--name-only", range).lines.map(&:strip).reject(&:empty?).first(200)
145
150
  commits = Integer(git_output(repo_root, "rev-list", "--count", range))
146
- stats = parse_git_shortstat(shortstat)
151
+ committed_paths = git_null_paths(repo_root, "diff", "--name-only", "-z", range)
152
+ .reject { |path| excluded_paths.include?(path) }
153
+ committed_stats = if committed_paths.empty?
154
+ { loc_added: 0, loc_removed: 0 }
155
+ else
156
+ parse_git_numstat(
157
+ git_output(repo_root, "diff", "--numstat", range, "--", *committed_paths.first(200))
158
+ )
159
+ end
160
+
161
+ start_worktree = start_capture[:worktree] || start_capture["worktree"]
162
+ end_worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
163
+ worktree_paths = changed_worktree_paths(start_worktree, end_worktree)
164
+ tracked_worktree_paths = worktree_paths.select do |path|
165
+ entry = end_worktree.fetch(:entries).fetch(path, nil)
166
+ entry && entry.fetch(:tracked)
167
+ end
168
+ worktree_stats = if tracked_worktree_paths.empty?
169
+ { loc_added: 0, loc_removed: 0 }
170
+ else
171
+ parse_git_numstat(
172
+ git_output(repo_root, "diff", "--numstat", end_sha, "--", *tracked_worktree_paths.first(200))
173
+ )
174
+ end
175
+ untracked_stats = untracked_worktree_stats(start_worktree, end_worktree, worktree_paths)
176
+ all_paths = (committed_paths + worktree_paths).uniq
147
177
 
148
178
  {
149
179
  sha: end_sha,
150
- loc_added: stats.fetch(:loc_added),
151
- loc_removed: stats.fetch(:loc_removed),
152
- files_changed: stats.fetch(:files_changed),
153
- changed_paths: changed_paths,
154
- commits: commits
180
+ loc_added: committed_stats.fetch(:loc_added) + worktree_stats.fetch(:loc_added) + untracked_stats.fetch(:loc_added),
181
+ loc_removed: committed_stats.fetch(:loc_removed) + worktree_stats.fetch(:loc_removed) + untracked_stats.fetch(:loc_removed),
182
+ files_changed: all_paths.length,
183
+ changed_paths: all_paths.first(200),
184
+ commits: commits,
185
+ start_dirty: start_worktree ? !start_worktree.fetch(:entries).empty? : nil,
186
+ end_dirty: !end_worktree.fetch(:entries).empty?,
187
+ worktree_changed: !worktree_paths.empty?
155
188
  }
156
189
  rescue StandardError
157
190
  {}
158
191
  end
159
192
 
193
+ def git_worktree_snapshot(repo_root, exclude_paths: [])
194
+ excluded_paths = normalize_git_excluded_paths(exclude_paths)
195
+ tracked_paths = (
196
+ git_null_paths(repo_root, "diff", "--name-only", "-z") +
197
+ git_null_paths(repo_root, "diff", "--cached", "--name-only", "-z")
198
+ ).uniq.reject { |path| excluded_paths.include?(path) }
199
+ untracked_paths = git_null_paths(repo_root, "ls-files", "--others", "--exclude-standard", "-z")
200
+ .reject { |path| excluded_paths.include?(path) }
201
+ entries = {}
202
+ tracked_paths.each do |path|
203
+ entries[path] = git_worktree_entry(repo_root, path, tracked: true)
204
+ end
205
+ untracked_paths.each do |path|
206
+ entries[path] = git_worktree_entry(repo_root, path, tracked: false)
207
+ end
208
+ { entries: entries }
209
+ end
210
+
211
+ def normalize_git_excluded_paths(paths)
212
+ Array(paths).filter_map do |path|
213
+ text = path.to_s.sub(%r{\A\./}, "")
214
+ text unless text.empty?
215
+ end.uniq
216
+ end
217
+
218
+ def git_worktree_entry(repo_root, relative_path, tracked:)
219
+ path = File.join(repo_root.to_s, relative_path)
220
+ stat = File.lstat(path)
221
+ if stat.symlink?
222
+ content = File.readlink(path)
223
+ return {
224
+ tracked: tracked,
225
+ fingerprint: "symlink:#{Digest::SHA256.hexdigest(content)}",
226
+ lines: 0,
227
+ binary: false
228
+ }
229
+ end
230
+
231
+ return { tracked: tracked, fingerprint: "missing", lines: 0, binary: false } unless stat.file?
232
+
233
+ if stat.size > GIT_FINGERPRINT_FULL_BYTES
234
+ first = File.binread(path, GIT_FINGERPRINT_SAMPLE_BYTES)
235
+ last = File.open(path, "rb") do |file|
236
+ file.seek(-[stat.size, GIT_FINGERPRINT_SAMPLE_BYTES].min, IO::SEEK_END)
237
+ file.read(GIT_FINGERPRINT_SAMPLE_BYTES).to_s
238
+ end
239
+ digest = Digest::SHA256.new
240
+ digest.update("#{stat.mode}:#{stat.size}:#{(stat.mtime.to_r * 1_000_000_000).to_i}")
241
+ digest.update(first)
242
+ digest.update(last)
243
+ return {
244
+ tracked: tracked,
245
+ fingerprint: "large:#{digest.hexdigest}",
246
+ lines: 0,
247
+ binary: first.include?("\0") || last.include?("\0")
248
+ }
249
+ end
250
+
251
+ digest = Digest::SHA256.new
252
+ lines = 0
253
+ binary = false
254
+ last_byte = nil
255
+ File.open(path, "rb") do |file|
256
+ buffer = +""
257
+ while file.read(16 * 1024, buffer)
258
+ digest.update(buffer)
259
+ binary ||= buffer.include?("\0")
260
+ lines += buffer.count("\n")
261
+ last_byte = buffer.byteslice(-1)
262
+ end
263
+ end
264
+ lines += 1 if stat.size.positive? && last_byte != "\n"
265
+ {
266
+ tracked: tracked,
267
+ fingerprint: "file:#{stat.mode}:#{digest.hexdigest}",
268
+ lines: binary ? 0 : lines,
269
+ binary: binary
270
+ }
271
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
272
+ { tracked: tracked, fingerprint: "missing", lines: 0, binary: false }
273
+ end
274
+
275
+ def changed_worktree_paths(start_worktree, end_worktree)
276
+ end_entries = end_worktree.fetch(:entries)
277
+ return end_entries.keys.sort unless start_worktree.is_a?(Hash)
278
+
279
+ start_entries = start_worktree[:entries] || start_worktree["entries"] || {}
280
+ (start_entries.keys + end_entries.keys).uniq.select do |path|
281
+ start_entries[path] != end_entries[path]
282
+ end.sort
283
+ end
284
+
285
+ def untracked_worktree_stats(start_worktree, end_worktree, changed_paths)
286
+ start_entries = if start_worktree.is_a?(Hash)
287
+ start_worktree[:entries] || start_worktree["entries"] || {}
288
+ else
289
+ {}
290
+ end
291
+ end_entries = end_worktree.fetch(:entries)
292
+ changed_paths.each_with_object({ loc_added: 0, loc_removed: 0 }) do |path, stats|
293
+ before = start_entries[path]
294
+ after = end_entries[path]
295
+ next if after && after.fetch(:tracked)
296
+ next if before && before.fetch(:tracked)
297
+
298
+ stats[:loc_removed] += before.fetch(:lines) if before
299
+ stats[:loc_added] += after.fetch(:lines) if after
300
+ end
301
+ end
302
+
303
+ def git_null_paths(repo_root, *args)
304
+ output, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
305
+ raise "git #{args.join(' ')} failed" unless status.success?
306
+
307
+ output.split("\0").reject(&:empty?)
308
+ end
309
+
310
+ def parse_git_numstat(text)
311
+ text.to_s.lines.each_with_object({ loc_added: 0, loc_removed: 0 }) do |line, stats|
312
+ added, removed, = line.split("\t", 3)
313
+ stats[:loc_added] += Integer(added, exception: false).to_i
314
+ stats[:loc_removed] += Integer(removed, exception: false).to_i
315
+ end
316
+ end
317
+
160
318
  # Canonical path resolution shared by every registry/exit/events writer and
161
319
  # reader. realpath collapses symlinked prefixes so a session registered from
162
320
  # a symlinked cwd stays visible to checkers resolving the physical path.
@@ -292,6 +450,12 @@ module Harnex
292
450
  false
293
451
  rescue Errno::EPERM
294
452
  true
453
+ rescue ArgumentError, TypeError
454
+ # A non-numeric pid means a truncated, hand-edited, or foreign-written
455
+ # registry file. Report it dead so active_sessions prunes it, the same
456
+ # self-healing it already applies to unparseable JSON. Raising here would
457
+ # take down every command that scans sessions -- status, send, pane.
458
+ false
295
459
  end
296
460
 
297
461
  def read_registry(repo_root, id = DEFAULT_ID, cli: nil)
@@ -359,10 +523,25 @@ module Harnex
359
523
  nil
360
524
  end
361
525
 
362
- def write_registry(path, payload)
363
- tmp = "#{path}.tmp.#{Process.pid}"
526
+ # Atomically replace a JSON state file.
527
+ #
528
+ # The temp name must be unique per write, not just per process. Several
529
+ # threads in one session write the same registry path — the startup persist,
530
+ # the inbox delivery thread, and one thread per API client — and a shared
531
+ # temp name lets one thread rename the file another is still writing, which
532
+ # surfaces as ENOENT on the loser's rename. mkdir_p covers a state directory
533
+ # that was reaped (tmpfs, operator cleanup) while the process was live.
534
+ def atomic_write_json(path, payload)
535
+ FileUtils.mkdir_p(File.dirname(path))
536
+ tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(6)}"
364
537
  File.write(tmp, JSON.pretty_generate(payload))
365
538
  File.rename(tmp, path)
539
+ ensure
540
+ FileUtils.rm_f(tmp) if tmp
541
+ end
542
+
543
+ def write_registry(path, payload)
544
+ atomic_write_json(path, payload)
366
545
  end
367
546
 
368
547
  def allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST)
@@ -9,6 +9,12 @@ module Harnex
9
9
 
10
10
  MAX_REPO_WALK_LEVELS = 10
11
11
 
12
+ # v2 marks the unified era: one rich dispatch_end row per dispatch
13
+ # carrying both the thin envelope and the summary sections. Readers
14
+ # key on record_type, not this stamp; legacy clauses keep accepting
15
+ # v1 and envelope-less rows mixed in the same file.
16
+ SCHEMA_VERSION = 2
17
+
12
18
  def global_path
13
19
  File.join(STATE_DIR, "dispatch.jsonl")
14
20
  end
@@ -138,7 +144,7 @@ module Harnex
138
144
  # trace; the dispatch_end row written in finalize_session! completes it.
139
145
  def build_start_record(session)
140
146
  {
141
- schema_version: 1,
147
+ schema_version: SCHEMA_VERSION,
142
148
  record_type: "dispatch_start",
143
149
  id: session.id,
144
150
  session_id: session.session_id,
@@ -150,16 +156,21 @@ module Harnex
150
156
  repo_root: session.repo_root,
151
157
  tier: session.__send__(:meta_hash)["tier"],
152
158
  meta: session.__send__(:meta_hash),
153
- summary_out_path: session.summary_out,
154
- events_log_path: session.events_log_path
159
+ events_log_path: session.events_log_path,
160
+ artifact_report_path: session.artifact_report_path,
161
+ artifact_claims_path: session.artifact_claims_path
155
162
  }
156
163
  end
157
164
 
165
+ # The v2 end row: the thin envelope merged with the rich summary
166
+ # sections. The envelope carries no raw meta passthrough — the summary's
167
+ # meta section (a superset with provenance) rides in its place; top-level
168
+ # tier stays for the history renderer.
158
169
  def build_record(session)
159
170
  ended_at = session.ended_at || Time.now
160
171
  status, terminal_event = classify(session)
161
172
  {
162
- schema_version: 1,
173
+ schema_version: SCHEMA_VERSION,
163
174
  record_type: "dispatch_end",
164
175
  id: session.id,
165
176
  session_id: session.session_id,
@@ -172,11 +183,9 @@ module Harnex
172
183
  terminal_event: terminal_event,
173
184
  commit_sha: commit_sha(session.git_start, session.git_end),
174
185
  tier: session.__send__(:meta_hash)["tier"],
175
- meta: session.__send__(:meta_hash),
176
- summary_out_path: session.summary_out,
177
186
  events_log_path: session.events_log_path,
178
187
  tmux_state: tmux_state(session.__send__(:summary_tmux_session))
179
- }
188
+ }.merge(session.__send__(:build_summary_record))
180
189
  end
181
190
 
182
191
  def classify(session)
@@ -0,0 +1,135 @@
1
+ module Harnex
2
+ # Static price table for computing usage.cost_usd when an adapter reports
3
+ # tokens but no cost (plan 33 Phase 2, locked decision 6). Cost is computed,
4
+ # never guessed: an unknown provider/model/service tier or a missing token
5
+ # component leaves cost_usd null.
6
+ #
7
+ # ## Update procedure
8
+ #
9
+ # Rates are USD per 1M tokens, copied by hand from the provider pricing
10
+ # pages at the URLs below. To update:
11
+ #
12
+ # 1. Fetch the provider's pricing page and read the per-1M-token rates for
13
+ # input, cached input (cache read), and output.
14
+ # 2. Replace the entry's rates and set `as_of` to the date you read them.
15
+ # Add new models as new entries; never delete an entry solely because a
16
+ # model left the pricing page (old rows in dispatch.jsonl were priced
17
+ # against it — the `as_of` on each row records which table vintage
18
+ # applied).
19
+ # 3. Update the expected costs in test/harnex/pricing_test.rb — rate
20
+ # changes are meant to be conscious, test-visible edits.
21
+ # 4. Never backfill: rows written before a table change keep the cost they
22
+ # were written with.
23
+ #
24
+ # Models whose pricing varies by service tier use a nested `service_tiers`
25
+ # table. For those models a nil or unknown service tier is unpriceable. A
26
+ # `max_context_tokens_exclusive` entry also requires an observed context
27
+ # high-water below that boundary; Harnex leaves cost null when context is
28
+ # missing or entered a differently-priced long-context tier. Do not infer
29
+ # standard/flex/fast or context tier outside the recorded measurements.
30
+ # OpenAI documents `priority` as the fast alias for gpt-5.5 short-context
31
+ # pricing; Harnex maps that alias only where the source supports it.
32
+ #
33
+ # Known scheduled change: Anthropic's claude-sonnet-5 entry below carries
34
+ # introductory pricing ($2/$10) that ends 2026-08-31; standard pricing is
35
+ # $3/$15 (cache read $0.30) from 2026-09-01. Refresh the entry then.
36
+ #
37
+ # ## Token semantics (verified 2026-08-02, plan 33 Phase 2)
38
+ #
39
+ # What input_tokens contains depends on the CAPTURE PATH, not just the
40
+ # provider, so the caller passes `input_includes_cached:` (sourced from
41
+ # `Adapters::Base#usage_input_includes_cached?`):
42
+ #
43
+ # - codex app-server (input_includes_cached: true): the JSON
44
+ # TokenUsageBreakdown reports cachedInputTokens as a subset of
45
+ # inputTokens and reasoningOutputTokens as a subset of outputTokens
46
+ # (verified against test/fixtures/codex_schema/v2 and a live captured
47
+ # row: input 3,084,697 + output 17,705 == total 3,102,402 exactly).
48
+ # Billable input = input - cached.
49
+ # - codex PTY (false): the scraped TUI line `input=X (+ Y cached)` shows
50
+ # NON-cached input with cached as a separate additive count (fixture:
51
+ # total 106,867 == input 104,158 + output 2,709, cached 250,880 > input).
52
+ # Billable input = input; cached prices additively at the cached rate.
53
+ # - anthropic (false): the Messages API reports input_tokens EXCLUSIVE of
54
+ # cache reads (cache_read_input_tokens is a separate field). Cache
55
+ # WRITES (cache_creation_input_tokens, billed at 1.25x input) have no
56
+ # harnex usage field yet — the Phase 3 Claude usage producer must either
57
+ # fold them into input_tokens or extend this formula before pricing rows
58
+ # that include cache writes.
59
+ #
60
+ # Reasoning tokens are never priced separately on any path — they are
61
+ # already inside output_tokens.
62
+ module Pricing
63
+ PRICES = {
64
+ "openai" => {
65
+ # Source: https://developers.openai.com/api/docs/pricing
66
+ "gpt-5.3-codex" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
67
+ # Short-context service-tier rates read 2026-08-03. Long-context
68
+ # rates are deliberately absent until exact source pricing is known.
69
+ "gpt-5.5" => {
70
+ max_context_tokens_exclusive: 272_000,
71
+ service_tier_aliases: { "priority" => "fast" }.freeze,
72
+ service_tiers: {
73
+ "standard" => { input: 5.00, cached_input: 0.50, output: 30.00, as_of: "2026-08-03" },
74
+ "flex" => { input: 2.50, cached_input: 0.25, output: 15.00, as_of: "2026-08-03" },
75
+ "fast" => { input: 12.50, cached_input: 1.25, output: 75.00, as_of: "2026-08-03" }
76
+ }.freeze
77
+ }.freeze,
78
+ "gpt-5.2" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
79
+ "gpt-5.1" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
80
+ "gpt-5" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
81
+ "gpt-5-mini" => { input: 0.25, cached_input: 0.025, output: 2.00, as_of: "2026-08-02" }
82
+ }.freeze,
83
+ "anthropic" => {
84
+ # Source: https://claude.com/platform/api (cache read = 0.1x input)
85
+ "claude-fable-5" => { input: 10.00, cached_input: 1.00, output: 50.00, as_of: "2026-08-02" },
86
+ "claude-opus-5" => { input: 5.00, cached_input: 0.50, output: 25.00, as_of: "2026-08-02" },
87
+ "claude-sonnet-5" => { input: 2.00, cached_input: 0.20, output: 10.00, as_of: "2026-08-02" },
88
+ "claude-haiku-4-5" => { input: 1.00, cached_input: 0.10, output: 5.00, as_of: "2026-08-02" }
89
+ }.freeze
90
+ }.freeze
91
+
92
+ module_function
93
+
94
+ # Returns { cost_usd:, as_of: } or nil when the cost cannot be computed
95
+ # (unknown provider/model/service tier, or input/output token counts missing).
96
+ def compute(provider:, model:, input_tokens:, output_tokens:, cached_tokens: nil,
97
+ service_tier: nil, context_tokens: nil,
98
+ input_includes_cached: false)
99
+ model_entry = PRICES.dig(provider.to_s, model.to_s)
100
+ return nil unless model_entry
101
+ return nil unless context_priceable?(model_entry, context_tokens)
102
+
103
+ entry = rates_for_service_tier(model_entry, service_tier)
104
+ return nil unless entry
105
+ return nil unless input_tokens.is_a?(Numeric) && output_tokens.is_a?(Numeric)
106
+
107
+ cached = cached_tokens.is_a?(Numeric) ? cached_tokens : 0
108
+ billable_input = input_includes_cached ? input_tokens - cached : input_tokens
109
+ billable_input = 0 if billable_input.negative?
110
+
111
+ cost = (billable_input * entry[:input] +
112
+ cached * entry[:cached_input] +
113
+ output_tokens * entry[:output]) / 1_000_000.0
114
+ { cost_usd: cost.round(6), as_of: entry[:as_of] }
115
+ end
116
+
117
+ def context_priceable?(entry, context_tokens)
118
+ limit = entry[:max_context_tokens_exclusive]
119
+ return true unless limit
120
+
121
+ context_tokens.is_a?(Numeric) && context_tokens >= 0 && context_tokens < limit
122
+ end
123
+
124
+ def rates_for_service_tier(entry, service_tier)
125
+ tiers = entry[:service_tiers]
126
+ return entry unless tiers
127
+
128
+ tier = service_tier.to_s
129
+ return nil if tier.empty?
130
+
131
+ aliases = entry[:service_tier_aliases] || {}
132
+ tiers[aliases.fetch(tier, tier)]
133
+ end
134
+ end
135
+ end