harnex 0.10.0 → 0.10.2

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: 25ba2b12b69cc6351e2ca4ee1e72e5f378a3828f7486498a2e2a026cf9da771b
4
+ data.tar.gz: 7e3cc95587c16dcb0a7b9a3e8339546a9ffbe085e4517407f1325e87fd2f45ce
5
5
  SHA512:
6
- metadata.gz: fe2b745dd618e1967fa5bbe1f7f55f4c0c2beb104cd6d213592f8545528118d3b3a80053b5643fce5482d3eaca9c7e6d800b6f5dd6d41873c0c9e5cc73aff332
7
- data.tar.gz: 0c13e6cb6fe6bbac64e14ae43b23061a2b5a96cbc7f4699d438b5e5eac48ef70435c017b638635c0ded52886536f94aac37df712b1d67a9c5485dc0dd1cfa4b7
6
+ metadata.gz: fcaa6d99632c375c1315bfc553b99ccf5c583b58a52e627e0af951493481bf33af9b5de50ca4704a1e6e2d7d9a8aa6999b42d94347ff942b76ac5033eb870dae
7
+ data.tar.gz: 929011b78aa399cc098b644f0bb2a0995222df71698e450e7ab4785e1be9249e07e4d40225fbf80e02ea9a11f6fefd7913c27682110944ccadacea8f879e7200
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.2] - 2026-08-08 | 06:49 PM | IST
4
+
5
+ Docs-only patch: no library or CLI behavior changes. Released so the
6
+ packaged `agents-guide` surfaces serve the corrected guidance instead of the
7
+ stale 0.10.1 copies.
8
+
9
+ ### Changed
10
+
11
+ - `guides/04_monitoring.md`: the Completion Test's clean-tree check now
12
+ documents excluding the harness-owned dispatch stream
13
+ (`git status --short -- . ':!.harnex'`) in repos that track
14
+ `.harnex/dispatch.jsonl`. New anti-patterns: claiming "no live sessions"
15
+ from memory (finished agents park at prompts — sweep proven-done sessions,
16
+ then prove with `harnex status`) and failing clean-tree fences on
17
+ dispatch-stream growth.
18
+ - `docs/dispatch-telemetry.md`: new "Clean-Tree Checks And The Tracked
19
+ Stream" section — the tracked stream grows mid-run and is harness-owned;
20
+ fences and worker briefs must exclude it and never revert it.
21
+
22
+ Context: holm Analysis 803 (queue-process failure meta-analysis). During
23
+ Q107 g2 a worker aborted after misreading dispatch-stream growth as foreign
24
+ dirt, and a coordinator claimed "no live sessions" while 12 completed
25
+ workers sat parked at prompts.
26
+
27
+ ## [0.10.1] - 2026-08-04 | 12:20 AM | IST
28
+
29
+ Patch release: closes the recovery and regression-checking gap left
30
+ after `0.10.0` removed the upstream telemetry mirror. The canonical
31
+ `.harnex/dispatch.jsonl` stream remains the only writer; this release adds
32
+ operator tooling to prove and repair that stream without schema migration.
33
+
34
+ ### Added
35
+
36
+ - `harnex telemetry assert-canonical` validates the canonical dispatch stream
37
+ and, when explicit `--source` paths are supplied, fails closed on missing or
38
+ conflicting rich end rows. It is read-only, tolerates mixed-era history
39
+ including open v2 starts and legacy rows, and emits bounded redacted reports.
40
+ - `harnex telemetry reconcile` uses the same analysis, defaults to dry-run, and
41
+ only appends missing rich end rows when `--apply` is passed. It never rewrites,
42
+ deletes, sorts, migrates, cleans source files, or discovers sources
43
+ automatically.
44
+ - Source paths may be files or directories. Directory scans are bounded to
45
+ `.json`/`.jsonl`, skip `.git`, symlinks, and the resolved canonical stream,
46
+ ignore unrelated generic JSON, and treat identity or payload conflicts as
47
+ zero-write failures.
48
+
3
49
  ## [0.10.0] - 2026-08-03 | 01:44 PM | IST
4
50
 
5
51
  Minor bump, **breaking**: `--summary-out` is removed outright, so the canonical
@@ -11,6 +11,16 @@ git repo it is `~/.local/state/harnex/dispatch.jsonl`. `harnex history`,
11
11
  `status --id`, and `wait` consume the same rows. Legacy v1 thin rows and
12
12
  pre-v2 envelope-less summaries may coexist and remain readable/skippable.
13
13
 
14
+ ## Clean-Tree Checks And The Tracked Stream
15
+
16
+ Some repos deliberately track `.harnex/dispatch.jsonl` as project telemetry.
17
+ There the stream grows while sessions run, so worker briefs, completion
18
+ fences, and orchestration clean-tree checks must treat the path as
19
+ harness-owned: expected to be dirty mid-run, never foreign dirt to abort on,
20
+ and never a file a worker should revert or "clean up". Exclude it explicitly,
21
+ for example `git status --porcelain -- . ':!.harnex'`, and commit its growth
22
+ alongside the work it describes.
23
+
14
24
  ## CLI flags
15
25
 
16
26
  ```text
@@ -66,6 +76,42 @@ harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005
66
76
 
67
77
  Use `harnex history --json | jq .` for pipelines over the repo-local log.
68
78
 
79
+ ## Canonical assertion and reconciliation
80
+
81
+ `harnex telemetry assert-canonical` is the read-only drift gate for the
82
+ canonical dispatch stream. Without sources it performs structural validation;
83
+ with explicit `--source PATH` inputs it also reports missing or conflicting rich
84
+ end rows and exits non-zero until the canonical stream is clean.
85
+
86
+ `harnex telemetry reconcile` runs the same analysis. It is a dry-run by default;
87
+ only `--apply` appends missing rich end rows, and then only after the canonical
88
+ stream and all source candidates have been parsed and conflict-checked. Apply
89
+ uses one append lock, rechecks under that lock, and is idempotent.
90
+
91
+ ```text
92
+ harnex telemetry assert-canonical [--canonical PATH | --global] [--source PATH ...] [--json]
93
+ harnex telemetry reconcile [--canonical PATH | --global] --source PATH [--source PATH ...] [--apply] [--json]
94
+ ```
95
+
96
+ The default canonical path is the repo-local `.harnex/dispatch.jsonl`, or the
97
+ global dispatch stream outside a git repo. `--canonical` and `--global` are
98
+ mutually exclusive. Sources are never discovered automatically: each `--source`
99
+ must be a file or directory. Directory scans consider regular `.json` and
100
+ `.jsonl` files, skip `.git`, symlinks, and the resolved canonical path, and
101
+ ignore unrelated JSON that does not match a rich Harnex dispatch end shape.
102
+
103
+ Mixed-era history is valid input. Legacy v1 thin rows, pre-v2 envelope-less rich
104
+ summaries, and v2 start/end rows may coexist. Open v2 starts are tolerated
105
+ because a running or interrupted dispatch may not have an end row yet. Identity
106
+ checks normalize equivalent timestamp offsets, and conflicts fail closed rather
107
+ than choosing a winner.
108
+
109
+ Reports are bounded and redacted: they contain counts, statuses, identities, and
110
+ path:line diagnostics, not raw telemetry payloads, prompts, claims, command
111
+ text, or rich sections. The commands never rewrite, delete, sort, migrate,
112
+ clean source files, reintroduce mirrors, or perform source discovery on their
113
+ own.
114
+
69
115
  ## Metadata and prediction contract
70
116
 
71
117
  The v2 `dispatch_end` always has `meta`, `predicted`, `actual`, `agent`,
@@ -100,6 +100,17 @@ terminal work signal.
100
100
  Adjust the artifact path to the task. The point is to avoid declaring done while
101
101
  a worker is between edits or between commits.
102
102
 
103
+ In repos that track the dispatch stream (`.harnex/dispatch.jsonl`), exclude it
104
+ from the clean-tree check — harnex appends to it during every run, so it is
105
+ legitimately dirty mid-run:
106
+
107
+ ```bash
108
+ test -z "$(git status --short -- . ':!.harnex')"
109
+ ```
110
+
111
+ Never treat harness-owned telemetry as foreign dirt, and never instruct a
112
+ worker to revert or "clean up" the stream to satisfy a fence.
113
+
103
114
  ## Why Pane State Alone Is Not Enough
104
115
 
105
116
  Avoid using `state=prompt` or a quiet pane as the only completion signal:
@@ -199,3 +210,8 @@ interpretation.
199
210
  - Reading raw tmux panes instead of `harnex pane`.
200
211
  - Using `--wait-for-idle` as acceptance proof.
201
212
  - Reusing a worker after a failure changes the task scope.
213
+ - Claiming "no live sessions" from memory. Finished agents park at prompts
214
+ indefinitely; sweep proven-done sessions, then prove the claim with
215
+ `harnex status`.
216
+ - Failing a clean-tree fence on `.harnex/dispatch.jsonl` growth in repos that
217
+ track the dispatch stream.
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.2"
3
+ RELEASE_DATE = "2026-08-08"
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.10.0
4
+ version: 0.10.2
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-03 00:00:00.000000000 Z
11
+ date: 2026-08-08 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