harnex 0.9.0 → 0.10.1

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.
@@ -0,0 +1,494 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "set"
4
+ require "time"
5
+
6
+ module Harnex
7
+ class TelemetryReconciler
8
+ SCHEMA = "harnex.telemetry_reconcile.v1"
9
+ FAMILY_KEYS = %w[v2_start v2_end v1_end legacy_rich legacy_unknown].freeze
10
+ RICH_KEYS = %w[predicted agent usage context attribution outcome attempt reliability].freeze
11
+ MAX_DIAGNOSTICS = 50
12
+
13
+ Result = Struct.new(:report, :exitstatus, keyword_init: true)
14
+ Row = Struct.new(:record, :path, :line, :family, :identity, :match_identity, :sort_key, keyword_init: true)
15
+
16
+ def initialize(command:, canonical:, sources:, apply: false)
17
+ @command = command
18
+ @canonical = canonical
19
+ @sources = sources
20
+ @apply = apply
21
+ @diagnostics = []
22
+ @diagnostics_truncated = 0
23
+ end
24
+
25
+ def run
26
+ base = analyze
27
+ return finish(base) unless @apply && writable?(base)
28
+
29
+ appended, locked = append_missing_under_lock(base[:missing_rows])
30
+ locked[:appended] = appended
31
+ return finish(locked) unless locked[:fatal].empty? && locked[:conflict_rows].empty?
32
+
33
+ post = analyze
34
+ post[:appended] = appended
35
+ finish(post)
36
+ end
37
+
38
+ private
39
+
40
+ def analyze
41
+ reset_diagnostics
42
+ canonical = load_canonical(@canonical)
43
+ sources = discover_sources(@sources, @canonical)
44
+ source_rows = sources[:rows]
45
+ conflict_rows = sources[:conflicts]
46
+ missing_rows = []
47
+ present = 0
48
+
49
+ unless canonical[:fatal].empty? || source_rows.empty?
50
+ return analysis(canonical, sources, present, missing_rows, conflict_rows)
51
+ end
52
+
53
+ source_rows.each do |row|
54
+ match = find_match(row, canonical[:recoverable])
55
+ if match.nil?
56
+ missing_rows << row
57
+ diagnostic("missing #{identity_label(row)} at #{row.path}:#{row.line}")
58
+ elsif rows_payload_equal?(row, match)
59
+ present += 1
60
+ else
61
+ conflict_rows << [row, match]
62
+ diagnostic("identity conflict #{identity_label(row)} at #{row.path}:#{row.line} vs #{match.path}:#{match.line}")
63
+ end
64
+ end
65
+
66
+ analysis(canonical, sources, present, missing_rows, conflict_rows)
67
+ end
68
+
69
+ def analysis(canonical, sources, present, missing_rows, conflict_rows)
70
+ {
71
+ canonical_rows: canonical[:rows],
72
+ families: canonical[:families],
73
+ recoverable: canonical[:recoverable],
74
+ sources: sources[:summary],
75
+ fatal: canonical[:fatal] + sources[:fatal],
76
+ present: present,
77
+ missing_rows: dedupe_rows(missing_rows),
78
+ conflict_rows: conflict_rows,
79
+ open_starts: canonical[:open_starts],
80
+ appended: 0
81
+ }
82
+ end
83
+
84
+ def finish(state)
85
+ state[:fatal].each { |message| diagnostic(message) }
86
+ status =
87
+ if state[:fatal].any?
88
+ "corrupt"
89
+ elsif state[:conflict_rows].any?
90
+ "conflict"
91
+ elsif state[:missing_rows].any?
92
+ "drift"
93
+ else
94
+ "clean"
95
+ end
96
+
97
+ report = {
98
+ schema: SCHEMA,
99
+ command: @command,
100
+ status: status,
101
+ canonical: @canonical,
102
+ canonical_rows: state[:canonical_rows],
103
+ families: state[:families],
104
+ sources: state[:sources],
105
+ present: state[:present],
106
+ missing: state[:missing_rows].length,
107
+ conflicts: state[:conflict_rows].length,
108
+ open_starts: state[:open_starts],
109
+ appended: state[:appended],
110
+ diagnostics: @diagnostics,
111
+ diagnostics_truncated: @diagnostics_truncated
112
+ }
113
+ Result.new(report: report, exitstatus: status == "clean" ? 0 : 1)
114
+ end
115
+
116
+ def writable?(state) = state[:fatal].empty? && state[:conflict_rows].empty? && state[:missing_rows].any?
117
+
118
+ def load_canonical(path)
119
+ rows = []
120
+ fatal = []
121
+ families = FAMILY_KEYS.to_h { |key| [key, 0] }
122
+ starts = {}
123
+ ends = {}
124
+ recoverable = []
125
+
126
+ read_jsonl(path).each do |entry|
127
+ if entry[:error]
128
+ fatal << "malformed canonical JSON at #{path}:#{entry[:line]}"
129
+ next
130
+ end
131
+ record = entry[:record]
132
+ rows << record
133
+ unless record.is_a?(Hash)
134
+ fatal << "canonical JSON object required at #{path}:#{entry[:line]}"
135
+ next
136
+ end
137
+
138
+ case canonical_family(record)
139
+ when :v2_start
140
+ families["v2_start"] += 1
141
+ validate_v2(record, path, entry[:line], fatal) do |identity|
142
+ add_unique(starts, identity, record, path, entry[:line], fatal, "v2 start")
143
+ end
144
+ when :v2_end
145
+ families["v2_end"] += 1
146
+ validate_v2(record, path, entry[:line], fatal) do |identity|
147
+ add_unique(ends, identity, record, path, entry[:line], fatal, "v2 end")
148
+ recoverable << build_row(record, path, entry[:line], :v2)
149
+ end
150
+ when :v1_end
151
+ families["v1_end"] += 1
152
+ when :legacy_rich
153
+ families["legacy_rich"] += 1
154
+ recoverable << build_row(record, path, entry[:line], :legacy)
155
+ else
156
+ families["legacy_unknown"] += 1
157
+ diagnostic("legacy_unknown at #{path}:#{entry[:line]}")
158
+ end
159
+ end
160
+
161
+ ends.each do |identity, row|
162
+ fatal << "unpaired v2 end #{identity.join('|')} at #{row.path}:#{row.line}" unless starts.key?(identity)
163
+ end
164
+
165
+ {
166
+ rows: rows.length,
167
+ families: families,
168
+ recoverable: recoverable,
169
+ fatal: fatal,
170
+ open_starts: (starts.keys - ends.keys).length
171
+ }
172
+ end
173
+
174
+ def validate_v2(record, path, line, fatal)
175
+ unless record["schema_version"] == Harnex::DispatchHistory::SCHEMA_VERSION
176
+ fatal << "invalid v2 schema_version at #{path}:#{line}"
177
+ return
178
+ end
179
+ id = record["id"].to_s
180
+ session_id = record["session_id"].to_s
181
+ started_at = normalized_time(record["started_at"])
182
+ if id.empty? || session_id.empty? || started_at.nil?
183
+ fatal << "invalid v2 identity at #{path}:#{line}"
184
+ return
185
+ end
186
+
187
+ yield(["v2", session_id, id, started_at])
188
+ end
189
+
190
+ def add_unique(index, identity, record, path, line, fatal, label)
191
+ prior = index[identity]
192
+ if prior
193
+ kind = payload_equal?(record, prior.record) ? "duplicate v2 identity" : "identity conflict"
194
+ fatal << "#{kind} for #{label} #{identity.join('|')} at #{path}:#{line}"
195
+ return
196
+ end
197
+
198
+ index[identity] = Row.new(record: record, path: path, line: line)
199
+ end
200
+
201
+ def discover_sources(paths, canonical_path)
202
+ unique = paths.map { |path| File.expand_path(path) }.uniq
203
+ files = unique.flat_map { |path| source_files(path, canonical_path) }
204
+ rows = []
205
+ fatal = []
206
+
207
+ files.each do |file|
208
+ parsed = parse_source_file(file[:path], explicit: file[:explicit])
209
+ fatal.concat(parsed[:fatal])
210
+ rows.concat(parsed[:rows])
211
+ end
212
+
213
+ conflicts = source_conflicts(rows)
214
+ conflicts.each { |left, right| diagnostic("identity conflict #{identity_label(left)} at #{left.path}:#{left.line} vs #{right.path}:#{right.line}") }
215
+
216
+ {
217
+ rows: dedupe_source_rows(rows),
218
+ fatal: fatal,
219
+ summary: {
220
+ "paths" => unique.length,
221
+ "files_scanned" => files.length,
222
+ "candidates" => rows.length
223
+ },
224
+ conflicts: conflicts
225
+ }
226
+ end
227
+
228
+ def source_files(path, canonical_path)
229
+ canonical_real = realpath_or_expand(canonical_path)
230
+ if File.file?(path) && !File.symlink?(path)
231
+ return [{ path: path, explicit: true }]
232
+ end
233
+ return [] unless File.directory?(path)
234
+
235
+ files = []
236
+ Dir.children(path).sort.each do |child|
237
+ child_path = File.join(path, child)
238
+ next if child == ".git" || File.symlink?(child_path)
239
+
240
+ if File.directory?(child_path)
241
+ files.concat(source_files(child_path, canonical_path))
242
+ elsif File.file?(child_path) && child_path.match?(/\.(jsonl?|JSONL?)\z/)
243
+ next if realpath_or_expand(child_path) == canonical_real
244
+
245
+ files << { path: child_path, explicit: false }
246
+ end
247
+ end
248
+ files
249
+ end
250
+
251
+ def parse_source_file(path, explicit:)
252
+ parsed = parse_whole_json(path)
253
+ entries = parsed ? json_entries(parsed, path) : read_jsonl(path)
254
+ errors = entries.select { |entry| entry[:error] }
255
+ rows = entries.filter_map do |entry|
256
+ next if entry[:error]
257
+
258
+ source_candidate(entry[:record], path, entry[:line])
259
+ end
260
+
261
+ fatal = []
262
+ if explicit && errors.any?
263
+ fatal << "malformed source telemetry at #{path}:#{errors.first[:line]}"
264
+ elsif !explicit && rows.any? && errors.any?
265
+ fatal << "malformed source telemetry at #{path}:#{errors.first[:line]}"
266
+ end
267
+ { rows: rows, fatal: fatal }
268
+ end
269
+
270
+ def parse_whole_json(path)
271
+ text = File.read(path)
272
+ return nil if text.strip.empty?
273
+
274
+ JSON.parse(text)
275
+ rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES
276
+ nil
277
+ end
278
+
279
+ def json_entries(value, path)
280
+ records = value.is_a?(Array) ? value : [value]
281
+ records.each_with_index.map { |record, index| { record: record, line: index + 1, path: path } }
282
+ end
283
+
284
+ def read_jsonl(path)
285
+ return [] unless File.file?(path)
286
+
287
+ File.readlines(path, chomp: true).each_with_index.filter_map do |line, index|
288
+ next if line.strip.empty?
289
+
290
+ begin
291
+ { record: JSON.parse(line), line: index + 1, path: path }
292
+ rescue JSON::ParserError => error
293
+ { error: error, line: index + 1, path: path }
294
+ end
295
+ end
296
+ end
297
+
298
+ def canonical_family(record)
299
+ if record["schema_version"] == 2 && record["record_type"] == "dispatch_start"
300
+ :v2_start
301
+ elsif record["schema_version"] == 2 && record["record_type"] == "dispatch_end"
302
+ :v2_end
303
+ elsif record["schema_version"] == 1 && record.key?("status") && !record.key?("record_type")
304
+ :v1_end
305
+ elsif legacy_rich?(record)
306
+ :legacy_rich
307
+ else
308
+ :legacy_unknown
309
+ end
310
+ end
311
+
312
+ def source_candidate(record, path, line)
313
+ if v2_rich_end?(record)
314
+ build_row(record, path, line, :v2)
315
+ elsif legacy_rich?(record)
316
+ build_row(record, path, line, :legacy)
317
+ end
318
+ end
319
+
320
+ def v2_rich_end?(record)
321
+ record.is_a?(Hash) &&
322
+ record["schema_version"] == 2 &&
323
+ record["record_type"] == "dispatch_end" &&
324
+ !record["id"].to_s.empty? &&
325
+ !record["session_id"].to_s.empty? &&
326
+ normalized_time(record["started_at"]) &&
327
+ record["actual"].is_a?(Hash)
328
+ end
329
+
330
+ def legacy_rich?(record)
331
+ return false unless record.is_a?(Hash)
332
+
333
+ meta = record["meta"]
334
+ rich_key_count = legacy_rich_key_count(record)
335
+ meta.is_a?(Hash) &&
336
+ !meta["id"].to_s.empty? &&
337
+ normalized_time(meta["started_at"]) &&
338
+ record["actual"].is_a?(Hash) &&
339
+ (rich_key_count >= 2 || meta["harness"] == "harnex" && rich_key_count.positive?)
340
+ end
341
+
342
+ def build_row(record, path, line, family)
343
+ id = family == :v2 ? record["id"].to_s : record.dig("meta", "id").to_s
344
+ started_at = normalized_time(family == :v2 ? record["started_at"] : record.dig("meta", "started_at"))
345
+ session_id = family == :v2 ? record["session_id"].to_s : nil
346
+ identity = family == :v2 ? ["v2", session_id, id, started_at] : ["legacy", id, started_at]
347
+ Row.new(
348
+ record: record,
349
+ path: path,
350
+ line: line,
351
+ family: family,
352
+ identity: identity,
353
+ match_identity: [id, started_at],
354
+ sort_key: [started_at, path, line]
355
+ )
356
+ end
357
+
358
+ def find_match(row, canonical_rows)
359
+ same_family = canonical_rows.find { |candidate| candidate.identity == row.identity }
360
+ return same_family if same_family
361
+
362
+ canonical_rows.find { |candidate| candidate.match_identity == row.match_identity }
363
+ end
364
+
365
+ def source_conflicts(rows)
366
+ conflicts = []
367
+ rows.group_by(&:identity).each_value do |group|
368
+ first = group.first
369
+ group.drop(1).each { |row| conflicts << [first, row] unless payload_equal?(first.record, row.record) }
370
+ end
371
+ rows.group_by(&:match_identity).each_value do |group|
372
+ next unless group.map(&:family).uniq.length > 1
373
+
374
+ group.combination(2) do |left, right|
375
+ next if left.family == right.family
376
+
377
+ conflicts << [left, right] unless rows_payload_equal?(left, right)
378
+ end
379
+ end
380
+ conflicts
381
+ end
382
+
383
+ def dedupe_source_rows(rows)
384
+ selected = []
385
+ rows.sort_by { |row| [row.sort_key[0], row.match_identity, family_preference(row), row.path, row.line] }.each do |row|
386
+ next if selected.any? { |candidate| candidate.identity == row.identity }
387
+
388
+ match = selected.find { |candidate| candidate.family != row.family && candidate.match_identity == row.match_identity }
389
+ next if match && rows_payload_equal?(match, row)
390
+
391
+ selected << row
392
+ end
393
+ selected
394
+ end
395
+
396
+ def dedupe_rows(rows)
397
+ seen = Set.new
398
+ rows.sort_by(&:sort_key).select do |row|
399
+ key = row.identity
400
+ next false if seen.include?(key)
401
+
402
+ seen << key
403
+ true
404
+ end
405
+ end
406
+
407
+ def legacy_rich_key_count(record) = (RICH_KEYS & record.keys).length
408
+
409
+ def family_preference(row) = row.family == :legacy ? 0 : 1
410
+
411
+ def append_missing_under_lock(rows)
412
+ FileUtils.mkdir_p(File.dirname(@canonical))
413
+ File.open(@canonical, File::RDWR | File::CREAT, 0o644) do |file|
414
+ file.flock(File::LOCK_EX)
415
+ file.rewind
416
+ existing = file.read
417
+ locked = analyze
418
+ return [0, locked] unless writable?(locked)
419
+
420
+ still_missing = locked[:missing_rows].select { |row| rows.any? { |wanted| wanted.identity == row.identity } }
421
+ payload = still_missing.map { |row| JSON.generate(row.record) }.join("\n")
422
+ payload = "\n#{payload}" unless existing.empty? || existing.end_with?("\n")
423
+ payload = "#{payload}\n" unless payload.empty?
424
+ file.seek(0, IO::SEEK_END)
425
+ file.write(payload)
426
+ file.flush
427
+ [still_missing.length, locked.merge(missing_rows: [])]
428
+ ensure
429
+ file.flock(File::LOCK_UN) unless file.closed?
430
+ end
431
+ end
432
+
433
+ def payload_equal?(left, right) = deep_normalize(left) == deep_normalize(right)
434
+
435
+ def rows_payload_equal?(left, right)
436
+ if left.family != right.family
437
+ comparable_rich_payload(left.record) == comparable_rich_payload(right.record)
438
+ else
439
+ payload_equal?(left.record, right.record)
440
+ end
441
+ end
442
+
443
+ def comparable_rich_payload(record)
444
+ keys = ["meta", "actual"] + RICH_KEYS
445
+ deep_normalize(record.select { |key, _value| keys.include?(key) })
446
+ end
447
+
448
+ def deep_normalize(value)
449
+ case value
450
+ when Hash
451
+ value.keys.sort.to_h do |key|
452
+ normalized =
453
+ if key == "started_at"
454
+ normalized_time(value[key]) || value[key]
455
+ else
456
+ deep_normalize(value[key])
457
+ end
458
+ [key, normalized]
459
+ end
460
+ when Array
461
+ value.map { |item| deep_normalize(item) }
462
+ else
463
+ value
464
+ end
465
+ end
466
+
467
+ def normalized_time(value)
468
+ Time.iso8601(value.to_s).utc.iso8601
469
+ rescue ArgumentError
470
+ nil
471
+ end
472
+
473
+ def realpath_or_expand(path)
474
+ File.realpath(path)
475
+ rescue Errno::ENOENT, Errno::EACCES
476
+ File.expand_path(path)
477
+ end
478
+
479
+ def identity_label(row) = row.identity.join("|")
480
+
481
+ def reset_diagnostics
482
+ @diagnostics = []
483
+ @diagnostics_truncated = 0
484
+ end
485
+
486
+ def diagnostic(message)
487
+ if @diagnostics.length < MAX_DIAGNOSTICS
488
+ @diagnostics << message
489
+ else
490
+ @diagnostics_truncated += 1
491
+ end
492
+ end
493
+ end
494
+ end
@@ -10,28 +10,18 @@ module Harnex
10
10
  root = File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root)
11
11
 
12
12
  latest_summary = nil
13
- latest_summary_path = nil
14
13
  latest_history = nil
15
14
 
15
+ # The canonical dispatch stream is the only source. Rows a prior
16
+ # release mirrored elsewhere are never read back, so a leftover mirror
17
+ # file on disk cannot resolve status from stale data.
16
18
  history_paths(root).each do |path|
17
19
  summary, history = scan_dispatch_path(path, normalized_id)
18
- if newer_summary?(summary, latest_summary)
19
- latest_summary = summary
20
- latest_summary_path = path
21
- end
20
+ latest_summary = summary if newer_summary?(summary, latest_summary)
22
21
  latest_history = history if newer_history?(history, latest_history)
23
22
  end
24
23
 
25
- summary_path = latest_history && latest_history["summary_out_path"].to_s.strip
26
- if summary_path && !summary_path.empty? && File.file?(summary_path)
27
- summary, = scan_dispatch_path(summary_path, normalized_id)
28
- if newer_summary?(summary, latest_summary)
29
- latest_summary = summary
30
- latest_summary_path = summary_path
31
- end
32
- end
33
-
34
- return build_from_summary(latest_summary, latest_summary_path, root) if latest_summary
24
+ return build_from_summary(latest_summary, root) if latest_summary
35
25
  return build_from_history(latest_history, root) if latest_history
36
26
 
37
27
  nil
@@ -52,7 +42,6 @@ module Harnex
52
42
  "artifact_report_status" => nil,
53
43
  "exit" => nil,
54
44
  "exit_code" => nil,
55
- "summary_out" => nil,
56
45
  "started_at" => nil,
57
46
  "ended_at" => nil,
58
47
  "source" => "none"
@@ -140,7 +129,7 @@ module Harnex
140
129
  nil
141
130
  end
142
131
 
143
- def build_from_summary(record, summary_path, fallback_repo_root)
132
+ def build_from_summary(record, fallback_repo_root)
144
133
  meta = record["meta"] || {}
145
134
  actual = record["actual"] || {}
146
135
  outcome = record["outcome"] || {}
@@ -162,10 +151,9 @@ module Harnex
162
151
  "artifact_report_status" => blank_to_nil(outcome["report_status"]),
163
152
  "exit" => blank_to_nil(actual["exit"]),
164
153
  "exit_code" => actual["exit_code"],
165
- "summary_out" => summary_path,
166
154
  "started_at" => meta["started_at"],
167
155
  "ended_at" => meta["ended_at"],
168
- "source" => "summary_out"
156
+ "source" => "dispatch_end"
169
157
  }
170
158
  end
171
159
 
@@ -208,7 +196,6 @@ module Harnex
208
196
  "artifact_report_status" => nil,
209
197
  "exit" => history_exit(status),
210
198
  "exit_code" => nil,
211
- "summary_out" => blank_to_nil(record["summary_out_path"]),
212
199
  "started_at" => record["started_at"],
213
200
  "ended_at" => record["ended_at"],
214
201
  "source" => "dispatch_history"
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.9.0"
3
- RELEASE_DATE = "2026-08-03"
2
+ VERSION = "0.10.1"
3
+ RELEASE_DATE = "2026-08-04"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "harnex/pricing"
11
11
  require_relative "harnex/terminal_status"
12
12
  require_relative "harnex/artifact_report"
13
13
  require_relative "harnex/context_telemetry"
14
+ require_relative "harnex/telemetry_reconciler"
14
15
  require_relative "harnex/orchestration"
15
16
  require_relative "harnex/watcher"
16
17
  require_relative "harnex/adapters"
@@ -37,4 +38,5 @@ require_relative "harnex/commands/agents_guide"
37
38
  require_relative "harnex/commands/doctor"
38
39
  require_relative "harnex/commands/orchestration"
39
40
  require_relative "harnex/commands/artifact_report"
41
+ require_relative "harnex/commands/telemetry"
40
42
  require_relative "harnex/cli"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-02 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
14
14
  and adds a control plane for discovery, messaging, and coordination.
@@ -61,6 +61,7 @@ files:
61
61
  - lib/harnex/commands/send.rb
62
62
  - lib/harnex/commands/status.rb
63
63
  - lib/harnex/commands/stop.rb
64
+ - lib/harnex/commands/telemetry.rb
64
65
  - lib/harnex/commands/wait.rb
65
66
  - lib/harnex/commands/watch.rb
66
67
  - lib/harnex/commands/watch_presets.rb
@@ -77,6 +78,7 @@ files:
77
78
  - lib/harnex/runtime/message.rb
78
79
  - lib/harnex/runtime/session.rb
79
80
  - lib/harnex/runtime/session_state.rb
81
+ - lib/harnex/telemetry_reconciler.rb
80
82
  - lib/harnex/terminal_status.rb
81
83
  - lib/harnex/version.rb
82
84
  - lib/harnex/watcher.rb