harnex 0.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0a3ac349311bc2677614dabb091443bb21ec82bf63852d237fb38fd47ecaeda
4
- data.tar.gz: f1b909c7ed43636ea57a297e25f2adc1d56283faa5f8af882ed22b63b4c55f2f
3
+ metadata.gz: 869ca6ae88939347c40b89586d058fcb64ea2785981155afebcfcb8ffcb7bf4f
4
+ data.tar.gz: e66e87cc0839ec329a394c9e17ba784e690d094d6ac639232dbc85638c11f9dc
5
5
  SHA512:
6
- metadata.gz: fe2b745dd618e1967fa5bbe1f7f55f4c0c2beb104cd6d213592f8545528118d3b3a80053b5643fce5482d3eaca9c7e6d800b6f5dd6d41873c0c9e5cc73aff332
7
- data.tar.gz: 0c13e6cb6fe6bbac64e14ae43b23061a2b5a96cbc7f4699d438b5e5eac48ef70435c017b638635c0ded52886536f94aac37df712b1d67a9c5485dc0dd1cfa4b7
6
+ metadata.gz: 6da098e592bd3937ceb378f8cd3c654675c213d3d5c79606a206eb5ddb043dba3e38bc3f05d39e9562add5f2779bd53981ca5a7ba1c17e85e1b9f837f3e59cc3
7
+ data.tar.gz: 63a34a367fd56167001f530aad631e8f8b17739fd7f209a949847245a68b66b79cef3664cf657918b3fea07c567482d738dbe16aa2773d1f5acd2ccda2a53318
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.1] - 2026-08-04 | 12:20 AM | IST
4
+
5
+ Patch release candidate: closes the recovery and regression-checking gap left
6
+ after `0.10.0` removed the upstream telemetry mirror. The canonical
7
+ `.harnex/dispatch.jsonl` stream remains the only writer; this release adds
8
+ operator tooling to prove and repair that stream without schema migration.
9
+
10
+ ### Added
11
+
12
+ - `harnex telemetry assert-canonical` validates the canonical dispatch stream
13
+ and, when explicit `--source` paths are supplied, fails closed on missing or
14
+ conflicting rich end rows. It is read-only, tolerates mixed-era history
15
+ including open v2 starts and legacy rows, and emits bounded redacted reports.
16
+ - `harnex telemetry reconcile` uses the same analysis, defaults to dry-run, and
17
+ only appends missing rich end rows when `--apply` is passed. It never rewrites,
18
+ deletes, sorts, migrates, cleans source files, or discovers sources
19
+ automatically.
20
+ - Source paths may be files or directories. Directory scans are bounded to
21
+ `.json`/`.jsonl`, skip `.git`, symlinks, and the resolved canonical stream,
22
+ ignore unrelated generic JSON, and treat identity or payload conflicts as
23
+ zero-write failures.
24
+
3
25
  ## [0.10.0] - 2026-08-03 | 01:44 PM | IST
4
26
 
5
27
  Minor bump, **breaking**: `--summary-out` is removed outright, so the canonical
@@ -66,6 +66,42 @@ harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005
66
66
 
67
67
  Use `harnex history --json | jq .` for pipelines over the repo-local log.
68
68
 
69
+ ## Canonical assertion and reconciliation
70
+
71
+ `harnex telemetry assert-canonical` is the read-only drift gate for the
72
+ canonical dispatch stream. Without sources it performs structural validation;
73
+ with explicit `--source PATH` inputs it also reports missing or conflicting rich
74
+ end rows and exits non-zero until the canonical stream is clean.
75
+
76
+ `harnex telemetry reconcile` runs the same analysis. It is a dry-run by default;
77
+ only `--apply` appends missing rich end rows, and then only after the canonical
78
+ stream and all source candidates have been parsed and conflict-checked. Apply
79
+ uses one append lock, rechecks under that lock, and is idempotent.
80
+
81
+ ```text
82
+ harnex telemetry assert-canonical [--canonical PATH | --global] [--source PATH ...] [--json]
83
+ harnex telemetry reconcile [--canonical PATH | --global] --source PATH [--source PATH ...] [--apply] [--json]
84
+ ```
85
+
86
+ The default canonical path is the repo-local `.harnex/dispatch.jsonl`, or the
87
+ global dispatch stream outside a git repo. `--canonical` and `--global` are
88
+ mutually exclusive. Sources are never discovered automatically: each `--source`
89
+ must be a file or directory. Directory scans consider regular `.json` and
90
+ `.jsonl` files, skip `.git`, symlinks, and the resolved canonical path, and
91
+ ignore unrelated JSON that does not match a rich Harnex dispatch end shape.
92
+
93
+ Mixed-era history is valid input. Legacy v1 thin rows, pre-v2 envelope-less rich
94
+ summaries, and v2 start/end rows may coexist. Open v2 starts are tolerated
95
+ because a running or interrupted dispatch may not have an end row yet. Identity
96
+ checks normalize equivalent timestamp offsets, and conflicts fail closed rather
97
+ than choosing a winner.
98
+
99
+ Reports are bounded and redacted: they contain counts, statuses, identities, and
100
+ path:line diagnostics, not raw telemetry payloads, prompts, claims, command
101
+ text, or rich sections. The commands never rewrite, delete, sort, migrate,
102
+ clean source files, reintroduce mirrors, or perform source discovery on their
103
+ own.
104
+
69
105
  ## Metadata and prediction contract
70
106
 
71
107
  The v2 `dispatch_end` always has `meta`, `predicted`, `actual`, `agent`,
data/lib/harnex/cli.rb CHANGED
@@ -41,6 +41,8 @@ module Harnex
41
41
  OrchestrationCommand.new(@argv.drop(1)).run
42
42
  when "artifact-report"
43
43
  ArtifactReportCommand.new(@argv.drop(1)).run
44
+ when "telemetry"
45
+ TelemetryCommand.new(@argv.drop(1)).run
44
46
  when "help"
45
47
  puts help(@argv[1])
46
48
  0
@@ -91,6 +93,8 @@ module Harnex
91
93
  OrchestrationCommand.usage
92
94
  when "artifact-report"
93
95
  ArtifactReportCommand.usage
96
+ when "telemetry"
97
+ TelemetryCommand.usage
94
98
  else
95
99
  usage
96
100
  end
@@ -113,6 +117,7 @@ module Harnex
113
117
  harnex doctor
114
118
  harnex orchestration sample|report [options]
115
119
  harnex artifact-report init|validate PATH [options]
120
+ harnex telemetry assert-canonical|reconcile [options]
116
121
  harnex help [command]
117
122
 
118
123
  Commands:
@@ -135,6 +140,8 @@ module Harnex
135
140
  Emit external primary samples and report orchestration tax
136
141
  artifact-report
137
142
  Validate harness-authored receipts or initialize legacy v1 documents
143
+ telemetry
144
+ Assert or reconcile canonical dispatch telemetry
138
145
  help Show command help
139
146
 
140
147
  New to harnex? Start with: harnex guide
@@ -159,6 +166,7 @@ module Harnex
159
166
  harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
160
167
  harnex artifact-report validate /path/from-dispatch-row.json --final
161
168
  harnex artifact-report init .harnex/reports/legacy-v1.json
169
+ harnex telemetry assert-canonical --canonical .harnex/dispatch.jsonl --json
162
170
  harnex send --id main --message "Summarize current progress."
163
171
  TEXT
164
172
  end
@@ -0,0 +1,110 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Harnex
5
+ class TelemetryCommand
6
+ COMMANDS = %w[assert-canonical reconcile].freeze
7
+
8
+ def self.usage(program_name = "harnex telemetry")
9
+ <<~TEXT
10
+ Usage:
11
+ #{program_name} assert-canonical [--canonical PATH | --global] [--source PATH] [--json]
12
+ #{program_name} reconcile [--canonical PATH | --global] --source PATH [--apply] [--json]
13
+
14
+ Options:
15
+ --canonical PATH Canonical dispatch JSONL path
16
+ --global Use the global harnex dispatch JSONL
17
+ --source PATH Source JSONL file or directory; repeatable
18
+ --apply Append missing records when reconciling
19
+ --json Emit JSON report
20
+ -h, --help Show this help
21
+ TEXT
22
+ end
23
+
24
+ def initialize(argv)
25
+ @argv = argv.dup
26
+ end
27
+
28
+ def run
29
+ command = @argv.shift
30
+ case command
31
+ when nil, "-h", "--help"
32
+ puts self.class.usage
33
+ 0
34
+ when *COMMANDS
35
+ options = parse_options(command, @argv)
36
+ if options[:help]
37
+ puts self.class.usage
38
+ return 0
39
+ end
40
+ validate_options!(command, options)
41
+ result = TelemetryReconciler.new(
42
+ command: command,
43
+ canonical: canonical_path(options),
44
+ sources: options[:sources],
45
+ apply: command == "reconcile" && options[:apply]
46
+ ).run
47
+ if options[:json]
48
+ puts JSON.generate(result.report)
49
+ else
50
+ puts render_human(result.report)
51
+ end
52
+ result.exitstatus
53
+ else
54
+ raise OptionParser::ParseError, "unknown telemetry subcommand #{command.inspect}"
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def parse_options(command, argv)
61
+ options = { command: command, sources: [], apply: false, json: false }
62
+ parser(options).parse!(argv)
63
+ raise OptionParser::InvalidArgument, "unexpected arguments: #{argv.join(' ')}" unless argv.empty?
64
+
65
+ options
66
+ end
67
+
68
+ def parser(options)
69
+ OptionParser.new do |opts|
70
+ opts.banner = "Usage: harnex telemetry #{options[:command]} [options]"
71
+ opts.on("--canonical PATH") { |value| options[:canonical] = value }
72
+ opts.on("--global") { options[:global] = true }
73
+ opts.on("--source PATH") { |value| options[:sources] << value }
74
+ opts.on("--apply") { options[:apply] = true }
75
+ opts.on("--json") { options[:json] = true }
76
+ opts.on("-h", "--help") { options[:help] = true }
77
+ end
78
+ end
79
+
80
+ def validate_options!(command, options)
81
+ if options[:canonical] && options[:global]
82
+ raise OptionParser::InvalidOption, "--canonical and --global are mutually exclusive"
83
+ end
84
+ if command == "assert-canonical" && options[:apply]
85
+ raise OptionParser::InvalidOption, "--apply is only supported with reconcile"
86
+ end
87
+ return unless command == "reconcile" && options[:sources].empty?
88
+
89
+ raise OptionParser::MissingArgument, "reconcile --source required"
90
+ end
91
+
92
+ def canonical_path(options)
93
+ return Harnex::DispatchHistory.global_path if options[:global]
94
+
95
+ options[:canonical] || Harnex::DispatchHistory.path_for(Dir.pwd)
96
+ end
97
+
98
+ def render_human(report)
99
+ lines = [
100
+ "telemetry #{report.fetch(:command)}: #{report.fetch(:status)}",
101
+ "canonical: #{report.fetch(:canonical)}",
102
+ "rows: #{report.fetch(:canonical_rows)} missing=#{report.fetch(:missing)} conflicts=#{report.fetch(:conflicts)} open_starts=#{report.fetch(:open_starts)} appended=#{report.fetch(:appended)}"
103
+ ]
104
+ report.fetch(:diagnostics).each { |diagnostic| lines << "diagnostic: #{diagnostic}" }
105
+ truncated = report.fetch(:diagnostics_truncated)
106
+ lines << "diagnostics truncated: #{truncated}" if truncated.to_i.positive?
107
+ lines.join("\n")
108
+ end
109
+ end
110
+ end
@@ -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
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.10.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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
@@ -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