harnex 0.7.9 → 0.7.11

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: 56d9111b2ab4f12716444ecef721331d9878a4485a4b305ecd4a1ad7546d4b77
4
- data.tar.gz: 3f727b473455245ba916cabe1202a6abc2d6c5a07f4ae257b04898a296d9149c
3
+ metadata.gz: 104afb9f09fe59bd24191fde1122294a35ae47069a929022228b4365d653da54
4
+ data.tar.gz: ac8032b88f3c0001718ffc053923000abbba33743e6de4b68459108ca481cbcf
5
5
  SHA512:
6
- metadata.gz: a2a87ae57e9ddc34bb85805ea9e07f3523691cf979361f687f4c3c68534c0b9f7c9e276b83d8d28a91a15bc52913ef41e1e36b1993127c97c68c87aa75af5766
7
- data.tar.gz: 92084ab180df22e16316226adaaa8170b0ab71ceeda1e4de3f097873ed6ecb8e22f8bca7b06b3c78b0a1a0c36d8b18c4b13dcebb7126aecd4523034fead38399
6
+ metadata.gz: f76c4a9847aebd46cca3bada6c0af0e622bb3840fa53160cb4811c2beac2a007c6a01faadf042ef22f73e55c2ddde5571adc56d774e5b444ece412ad656242e4
7
+ data.tar.gz: 1d9843aa631eb411fa31c772cf5c09b92cc5d5d67ed95583abf041fd250e31343c76215a8949f268563e14609416724fd681bad6bfc5c6b7c270a4f9639e6838
data/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.11] - 2026-07-10 | 11:14 PM | IST
6
+
7
+ ### Added
8
+
9
+ - Dispatch summaries now include top-level additive `agent` and `reliability`
10
+ blocks for every run, and a top-level `queue` block when queue attribution is
11
+ provided. New consumers should prefer these blocks over legacy flat fields for
12
+ queue economics and reliability analytics.
13
+ - `harnex run` now accepts first-class queue/agent telemetry flags:
14
+ `--project-id`, `--queue-id`, `--entry-id`, `--entry-title`, `--phase`,
15
+ `--tier`, `--issue`, `--plan`, `--intent`, `--model`, and `--effort`.
16
+ Explicit flags override same-named `--meta` values and are forwarded through
17
+ tmux re-exec.
18
+ - `harnex run --require-attribution` fails before launching unless required
19
+ queue attribution is present (`project_id`, `phase`, `intent`, and at least
20
+ one work id).
21
+
22
+ ### Changed
23
+
24
+ - Dispatch telemetry docs, README, and the packaged dispatch agents-guide now
25
+ document queue attribution, effective agent routing fields, reliability split,
26
+ and a sample queue grouping snippet.
27
+
28
+ ## [0.7.10] - 2026-07-10 | 10:57 PM | IST
29
+
30
+ ### Added
31
+
32
+ - `harnex run --artifact-report PATH` / `--validation-report PATH` now expose a
33
+ worker-writable `harnex.artifact_report.v1` sidecar path via
34
+ `HARNEX_ARTIFACT_REPORT_PATH` and `HARNEX_VALIDATION_REPORT_PATH`, ingest the
35
+ report at dispatch finalization, and append compact top-level
36
+ `artifact_report`, `validation`, and `artifacts` blocks to the dispatch
37
+ summary row.
38
+
39
+ ### Changed
40
+
41
+ - Dispatch telemetry docs, README, and the packaged dispatch agents-guide now
42
+ document sidecar proof while keeping plain-text `koder/` artifacts canonical.
43
+
44
+ ### Fixed
45
+
46
+ - Missing, malformed, unsupported-schema, and oversized artifact reports now
47
+ fail soft with `artifact_report.ingest_status` warning telemetry instead of
48
+ crashing or changing the wrapped process exit code.
49
+
5
50
  ## [0.7.9] - 2026-07-10 | 10:33 PM | IST
6
51
 
7
52
  ### Added
data/README.md CHANGED
@@ -268,9 +268,34 @@ Dispatch briefs can declare soft budget metadata through `--meta`:
268
268
  harnex run pi --meta '{"read_budget_lines":2000,"output_ceiling_lines":800}' ...
269
269
  ```
270
270
 
271
- Those declared values are copied into summary `meta`. Terminal summary
272
- `actual` records timing, exit classification, token usage when the adapter can
273
- capture it, adapter-reported `cost_usd` when reliably available, git deltas,
271
+ Workers can also write a small machine-readable proof sidecar while keeping the
272
+ canonical explanation in plain-text `koder/` files:
273
+
274
+ ```bash
275
+ harnex run pi --id pi-i-52 \
276
+ --artifact-report .harnex/reports/pi-i-52.json \
277
+ --context 'Run validation, update koder/issues/52.md, and write JSON proof to $HARNEX_ARTIFACT_REPORT_PATH' \
278
+ --auto-stop
279
+ ```
280
+
281
+ The sidecar schema is `harnex.artifact_report.v1`; harnex exposes the path as
282
+ `HARNEX_ARTIFACT_REPORT_PATH` / `HARNEX_VALIDATION_REPORT_PATH`, then records a
283
+ compact `artifact_report`, `validation`, and `artifacts` summary in the dispatch
284
+ row. Missing or malformed reports are warning telemetry, not wrapped-process
285
+ crashes.
286
+
287
+ Queue runners can pass first-class attribution without hiding it in prose:
288
+
289
+ ```bash
290
+ harnex run pi --project-id harnex --queue-id queue-005 --entry-id SP-4 \
291
+ --phase implement --intent queue-work --require-attribution ...
292
+ ```
293
+
294
+ Soft budget metadata is copied into summary `meta`; queue/agent/reliability
295
+ metadata is copied into top-level `queue`, `agent`, and `reliability` summary
296
+ blocks. Terminal summary `actual` records timing, exit classification, token
297
+ usage when the adapter can capture it, adapter-reported `cost_usd` when
298
+ reliably available, git deltas,
274
299
  task-completion state, operational counters (`stalls`, `force_resumes`,
275
300
  `disconnections`, `tool_calls`, `commands_executed`), output/event log paths,
276
301
  and rough volume measurements such as `lines_changed`, `output_lines`,
@@ -95,6 +95,36 @@ harnex run codex --cwd /tmp/leximaze_eval_run_001 \
95
95
  `--root DIR` only overrides harnex's root attribution; it does not change the
96
96
  child process cwd. Neither flag is a sandbox.
97
97
 
98
+ For queue closeout, ask workers to write a compact sidecar in addition to their
99
+ plain-text `koder/` artifact:
100
+
101
+ ```bash
102
+ harnex run pi --id pi-i-NN --tmux pi-i-NN \
103
+ --artifact-report .harnex/reports/pi-i-NN.json \
104
+ --context 'Update the canonical koder file and write harnex.artifact_report.v1 proof to $HARNEX_ARTIFACT_REPORT_PATH' \
105
+ --auto-stop
106
+ ```
107
+
108
+ The worker should keep the full explanation in `koder/` and put only compact
109
+ machine-readable proof in the sidecar: validation command/status, typed
110
+ artifact summaries (`finding`, `review`, `gate`, `blocker`, etc.), evidence,
111
+ confidence, and canonical refs. Harnex records missing/malformed sidecars as
112
+ warning telemetry instead of failing the wrapped process.
113
+
114
+ Queue runners should pass first-class attribution so dispatch rows can be grouped
115
+ without path/id heuristics:
116
+
117
+ ```bash
118
+ harnex run pi --id pi-i-NN --tmux pi-i-NN \
119
+ --project-id harnex --queue-id queue-005 --entry-id SP-4 \
120
+ --phase implement --intent queue-work --require-attribution \
121
+ --context "Read and execute /tmp/task-impl-NN.md"
122
+ ```
123
+
124
+ `--require-attribution` fails before launch unless `project_id`, `phase`,
125
+ `intent`, and at least one work id (`queue_id`, `entry_id`, `issue`, or `plan`)
126
+ are present.
127
+
98
128
  Pi runs use structured RPC (`pi --mode rpc`). Pass Pi child flags after `--`
99
129
  (e.g. `harnex run pi --context "..." -- --model anthropic/claude-sonnet-4-5 --thinking high`).
100
130
 
@@ -0,0 +1,221 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module Harnex
5
+ module ArtifactReport
6
+ SCHEMA = "harnex.artifact_report.v1"
7
+ MAX_BYTES = 256 * 1024
8
+ MAX_ARTIFACTS = 50
9
+ MAX_COMMANDS = 50
10
+ MAX_CANONICAL_ARTIFACTS = 50
11
+ MAX_EVIDENCE_ITEMS = 20
12
+ MAX_STRING_LENGTH = 2_000
13
+
14
+ module_function
15
+
16
+ def ingest(path)
17
+ report_path = File.expand_path(path.to_s)
18
+ return missing(report_path) unless File.file?(report_path)
19
+
20
+ bytes = File.size(report_path)
21
+ sha256 = file_sha256(report_path)
22
+ if bytes > MAX_BYTES
23
+ return warning(
24
+ report_path,
25
+ bytes: bytes,
26
+ sha256: sha256,
27
+ ingest_status: "oversized",
28
+ warning: "artifact report is #{bytes} bytes; max is #{MAX_BYTES} bytes"
29
+ )
30
+ end
31
+
32
+ parsed = JSON.parse(File.read(report_path, mode: "rb"))
33
+ unless parsed.is_a?(Hash)
34
+ return warning(
35
+ report_path,
36
+ bytes: bytes,
37
+ sha256: sha256,
38
+ ingest_status: "malformed",
39
+ warning: "artifact report must be a JSON object"
40
+ )
41
+ end
42
+
43
+ schema = parsed["schema"].to_s
44
+ unless schema == SCHEMA
45
+ return warning(
46
+ report_path,
47
+ bytes: bytes,
48
+ sha256: sha256,
49
+ ingest_status: "unsupported_schema",
50
+ schema: schema.empty? ? nil : bounded_string(schema),
51
+ warning: "unsupported artifact report schema #{schema.inspect}; expected #{SCHEMA}"
52
+ )
53
+ end
54
+
55
+ build_payload(report_path, bytes: bytes, sha256: sha256, report: parsed)
56
+ rescue JSON::ParserError => e
57
+ warning(
58
+ report_path,
59
+ bytes: safe_file_size(report_path),
60
+ sha256: safe_file_sha256(report_path),
61
+ ingest_status: "malformed",
62
+ warning: "malformed artifact report JSON: #{bounded_string(e.message)}"
63
+ )
64
+ rescue StandardError => e
65
+ warning(
66
+ report_path,
67
+ bytes: safe_file_size(report_path),
68
+ sha256: safe_file_sha256(report_path),
69
+ ingest_status: "error",
70
+ warning: "artifact report ingest failed: #{bounded_string(e.message)}"
71
+ )
72
+ end
73
+
74
+ def build_payload(path, bytes:, sha256:, report:)
75
+ artifacts = compact_artifacts(report["artifacts"])
76
+ validation = compact_validation(report["validation"])
77
+ payload = {
78
+ "artifact_report" => metadata(
79
+ path,
80
+ bytes: bytes,
81
+ sha256: sha256,
82
+ ingest_status: "ok",
83
+ schema: SCHEMA
84
+ ).merge(
85
+ "report_status" => bounded_string_or_nil(report["status"]),
86
+ "canonical_artifacts" => string_array(report["canonical_artifacts"], max_items: MAX_CANONICAL_ARTIFACTS),
87
+ "artifact_count" => artifacts.length
88
+ )
89
+ }
90
+ payload["validation"] = validation if validation
91
+ payload["artifacts"] = artifacts unless artifacts.empty?
92
+ payload
93
+ end
94
+
95
+ def missing(path)
96
+ warning(
97
+ path,
98
+ bytes: nil,
99
+ sha256: nil,
100
+ ingest_status: "missing",
101
+ warning: "artifact report not found"
102
+ )
103
+ end
104
+
105
+ def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil)
106
+ report = metadata(path, bytes: bytes, sha256: sha256, ingest_status: ingest_status, schema: schema)
107
+ report["warning"] = bounded_string(warning)
108
+ { "artifact_report" => report }
109
+ end
110
+
111
+ def metadata(path, bytes:, sha256:, ingest_status:, schema: nil)
112
+ {
113
+ "path" => path.to_s,
114
+ "bytes" => bytes,
115
+ "sha256" => sha256,
116
+ "ingest_status" => ingest_status,
117
+ "schema" => schema
118
+ }
119
+ end
120
+
121
+ def compact_validation(value)
122
+ return nil unless value.is_a?(Hash)
123
+
124
+ payload = {}
125
+ payload["status"] = bounded_string_or_nil(value["status"])
126
+ payload["commands"] = compact_commands(value["commands"])
127
+ payload["final_reported"] = !!value["final_reported"] if value.key?("final_reported")
128
+ payload.delete_if { |_key, v| v.nil? || v == [] }
129
+ payload.empty? ? nil : payload
130
+ end
131
+
132
+ def compact_commands(value)
133
+ Array(value).first(MAX_COMMANDS).filter_map do |entry|
134
+ next unless entry.is_a?(Hash)
135
+
136
+ compact = {
137
+ "cmd" => bounded_string_or_nil(entry["cmd"]),
138
+ "exit_code" => integer_or_nil(entry["exit_code"]),
139
+ "status" => bounded_string_or_nil(entry["status"]),
140
+ "duration_s" => finite_float_or_nil(entry["duration_s"])
141
+ }
142
+ compact.delete_if { |_key, v| v.nil? }
143
+ compact.empty? ? nil : compact
144
+ end
145
+ end
146
+
147
+ def compact_artifacts(value)
148
+ Array(value).first(MAX_ARTIFACTS).filter_map do |entry|
149
+ next unless entry.is_a?(Hash)
150
+
151
+ compact = {
152
+ "type" => bounded_string_or_nil(entry["type"]),
153
+ "summary" => bounded_string_or_nil(entry["summary"]),
154
+ "evidence" => string_array(entry["evidence"], max_items: MAX_EVIDENCE_ITEMS),
155
+ "confidence" => finite_float_or_nil(entry["confidence"]),
156
+ "canonical_ref" => bounded_string_or_nil(entry["canonical_ref"])
157
+ }
158
+ compact.delete_if { |_key, v| v.nil? || v == [] }
159
+ compact.empty? ? nil : compact
160
+ end
161
+ end
162
+
163
+ def string_array(value, max_items:)
164
+ Array(value).first(max_items).filter_map do |item|
165
+ text = bounded_string_or_nil(item)
166
+ text unless text.nil? || text.empty?
167
+ end
168
+ end
169
+
170
+ def bounded_string_or_nil(value)
171
+ return nil if value.nil?
172
+
173
+ bounded_string(value)
174
+ end
175
+
176
+ def bounded_string(value)
177
+ text = value.to_s
178
+ return text if text.length <= MAX_STRING_LENGTH
179
+
180
+ text[0, MAX_STRING_LENGTH]
181
+ end
182
+
183
+ def integer_or_nil(value)
184
+ return nil if value.nil?
185
+
186
+ Integer(value)
187
+ rescue ArgumentError, TypeError
188
+ nil
189
+ end
190
+
191
+ def finite_float_or_nil(value)
192
+ return nil if value.nil?
193
+
194
+ number = Float(value)
195
+ number.finite? ? number : nil
196
+ rescue ArgumentError, TypeError
197
+ nil
198
+ end
199
+
200
+ def file_sha256(path)
201
+ digest = Digest::SHA256.new
202
+ File.open(path, "rb") do |file|
203
+ buffer = +""
204
+ digest.update(buffer) while file.read(16 * 1024, buffer)
205
+ end
206
+ digest.hexdigest
207
+ end
208
+
209
+ def safe_file_size(path)
210
+ File.size(path) if File.file?(path)
211
+ rescue StandardError
212
+ nil
213
+ end
214
+
215
+ def safe_file_sha256(path)
216
+ file_sha256(path) if File.file?(path)
217
+ rescue StandardError
218
+ nil
219
+ end
220
+ end
221
+ end
@@ -5,15 +5,33 @@ require "shellwords"
5
5
  module Harnex
6
6
  class Runner
7
7
  DEFAULT_TIMEOUT = 5.0
8
+ TELEMETRY_FLAGS = {
9
+ "--project-id" => "project_id",
10
+ "--queue-id" => "queue_id",
11
+ "--entry-id" => "entry_id",
12
+ "--entry-title" => "entry_title",
13
+ "--phase" => "phase",
14
+ "--tier" => "tier",
15
+ "--issue" => "issue",
16
+ "--plan" => "plan",
17
+ "--intent" => "intent",
18
+ "--model" => "model",
19
+ "--effort" => "effort"
20
+ }.freeze
21
+ TELEMETRY_KEYS_TO_FLAGS = TELEMETRY_FLAGS.invert.freeze
22
+ TELEMETRY_EQUALS_PREFIXES = TELEMETRY_FLAGS.keys.map { |flag| "#{flag}=" }.freeze
23
+
8
24
  KNOWN_FLAGS = %w[
9
25
  --id --description --detach --tmux --host --port --watch --watch-file
10
26
  --stall-after --max-resumes --preset --context --meta --summary-out
11
- --cwd --root --timeout --inbox-ttl --auto-stop --fast --legacy-pty --help
12
- ].freeze
27
+ --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
28
+ --require-attribution --auto-stop --fast --legacy-pty --help
29
+ ].concat(TELEMETRY_FLAGS.keys).freeze
13
30
  VALUE_FLAGS = %w[
14
31
  --id --description --host --port --watch --watch-file --stall-after
15
- --max-resumes --preset --context --meta --summary-out --cwd --root --timeout --inbox-ttl
16
- ].freeze
32
+ --max-resumes --preset --context --meta --summary-out --artifact-report
33
+ --validation-report --cwd --root --timeout --inbox-ttl
34
+ ].concat(TELEMETRY_FLAGS.keys).freeze
17
35
 
18
36
  def self.usage(program_name = "harnex run")
19
37
  <<~TEXT
@@ -37,6 +55,23 @@ module Harnex
37
55
  Default Codex runs force service_tier="flex".
38
56
  --meta JSON Attach parsed JSON metadata to the started event
39
57
  --summary-out PATH Append dispatch telemetry summary JSONL to PATH
58
+ --artifact-report PATH
59
+ Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
60
+ --validation-report PATH
61
+ Alias for --artifact-report; also exposed as HARNEX_VALIDATION_REPORT_PATH
62
+ --project-id ID Queue telemetry project id (first-class flags override --meta)
63
+ --queue-id ID Queue telemetry queue id
64
+ --entry-id ID Queue telemetry entry id
65
+ --entry-title TEXT Queue telemetry entry title
66
+ --phase TEXT Queue/work phase telemetry
67
+ --tier TEXT Queue/work tier telemetry
68
+ --issue ID Queue/work issue telemetry
69
+ --plan ID Queue/work plan telemetry
70
+ --intent TEXT Queue/work intent telemetry
71
+ --model NAME Requested model metadata (also used for structured dispatch)
72
+ --effort LEVEL Requested reasoning effort metadata (structured dispatch)
73
+ --require-attribution
74
+ Fail before launch unless project/phase/intent and one work id are present
40
75
  --cwd DIR Run the wrapped agent from DIR and use DIR as the session root
41
76
  --root DIR Override harnex session/root attribution without changing child cwd
42
77
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
@@ -61,6 +96,8 @@ module Harnex
61
96
  #{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
62
97
  #{program_name} pi --id pi-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
63
98
  #{program_name} codex --cwd /tmp/public-bundle --id eval-001 --context "Read README.md and write OUTPUT.md" --auto-stop
99
+ #{program_name} pi --id pi-i-52 --artifact-report .harnex/reports/pi-i-52.json --context "Write proof to $HARNEX_ARTIFACT_REPORT_PATH" --auto-stop
100
+ #{program_name} pi --project-id harnex --queue-id queue-005 --entry-id SP-4 --phase implement --intent queue-work --require-attribution --context "Implement SP-4"
64
101
  #{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
65
102
 
66
103
  Gotchas:
@@ -90,7 +127,10 @@ module Harnex
90
127
  watch: nil,
91
128
  context: nil,
92
129
  meta: nil,
130
+ telemetry: {},
131
+ require_attribution: false,
93
132
  summary_out: nil,
133
+ artifact_report: nil,
94
134
  cwd: nil,
95
135
  root: nil,
96
136
  auto_stop: false,
@@ -114,9 +154,12 @@ module Harnex
114
154
 
115
155
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
116
156
  validate_auto_stop_context!
157
+ apply_telemetry_options!
158
+ validate_required_attribution!
117
159
 
118
160
  repo_root = resolve_run_root(cli_name, child_args)
119
161
  @options[:summary_out] = resolve_summary_out(repo_root)
162
+ @options[:artifact_report] = resolve_artifact_report(repo_root)
120
163
  @options[:id] ||= Harnex.generate_id(repo_root)
121
164
  validate_unique_id!(repo_root)
122
165
  effective_child_args = apply_context(apply_codex_service_tier(cli_name, child_args))
@@ -177,7 +220,13 @@ module Harnex
177
220
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
178
221
  tmux_cmd << "--auto-stop" if @options[:auto_stop]
179
222
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
223
+ @options[:telemetry].each do |key, value|
224
+ flag = TELEMETRY_KEYS_TO_FLAGS[key]
225
+ tmux_cmd += [flag, value] if flag && value
226
+ end
227
+ tmux_cmd << "--require-attribution" if @options[:require_attribution]
180
228
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
229
+ tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
181
230
  tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
182
231
  tmux_cmd += ["--root", @options[:root]] if @options[:root]
183
232
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
@@ -287,6 +336,7 @@ module Harnex
287
336
  description: @options[:description],
288
337
  meta: @options[:meta],
289
338
  summary_out: @options[:summary_out],
339
+ artifact_report_path: @options[:artifact_report],
290
340
  inbox_ttl: @options[:inbox_ttl],
291
341
  auto_stop: @options[:auto_stop],
292
342
  launch_cwd: history_cwd,
@@ -456,6 +506,8 @@ module Harnex
456
506
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
457
507
  when "--auto-stop"
458
508
  @options[:auto_stop] = true
509
+ when "--require-attribution"
510
+ @options[:require_attribution] = true
459
511
  when "--fast"
460
512
  @options[:fast] = true
461
513
  when "--meta"
@@ -463,11 +515,24 @@ module Harnex
463
515
  @options[:meta] = parse_meta(required_option_value(arg, argv[index]))
464
516
  when /\A--meta=(.+)\z/
465
517
  @options[:meta] = parse_meta(required_option_value("--meta", Regexp.last_match(1)))
518
+ when *TELEMETRY_FLAGS.keys
519
+ index += 1
520
+ @options[:telemetry][TELEMETRY_FLAGS.fetch(arg)] = required_option_value(arg, argv[index])
521
+ when telemetry_equals_regex
522
+ flag = "--#{Regexp.last_match(1)}"
523
+ @options[:telemetry][TELEMETRY_FLAGS.fetch(flag)] = required_option_value(flag, Regexp.last_match(2))
466
524
  when "--summary-out"
467
525
  index += 1
468
526
  @options[:summary_out] = required_option_value(arg, argv[index])
469
527
  when /\A--summary-out=(.+)\z/
470
528
  @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
529
+ when "--artifact-report", "--validation-report"
530
+ index += 1
531
+ @options[:artifact_report] = required_option_value(arg, argv[index])
532
+ when /\A--artifact-report=(.+)\z/
533
+ @options[:artifact_report] = required_option_value("--artifact-report", Regexp.last_match(1))
534
+ when /\A--validation-report=(.+)\z/
535
+ @options[:artifact_report] = required_option_value("--validation-report", Regexp.last_match(1))
471
536
  when "--cwd"
472
537
  index += 1
473
538
  @options[:cwd] = expand_existing_directory(required_option_value(arg, argv[index]), option_name: arg)
@@ -504,6 +569,10 @@ module Harnex
504
569
  [cli_name, forwarded]
505
570
  end
506
571
 
572
+ def telemetry_equals_regex
573
+ @telemetry_equals_regex ||= /\A--(#{TELEMETRY_FLAGS.keys.map { |flag| Regexp.escape(flag.delete_prefix("--")) }.join("|")})=(.+)\z/
574
+ end
575
+
507
576
  def unknown_long_flag?(arg)
508
577
  arg.start_with?("--")
509
578
  end
@@ -540,13 +609,15 @@ module Harnex
540
609
  case arg
541
610
  when "--"
542
611
  return false
543
- when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--fast", "--legacy-pty"
612
+ when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-attribution", "--fast", "--legacy-pty"
544
613
  nil
545
614
  when /\A--tmux=/
546
615
  nil
547
616
  when *VALUE_FLAGS
548
617
  index += 1
549
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|cwd|root|timeout|inbox-ttl)=/
618
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|artifact-report|validation-report|cwd|root|timeout|inbox-ttl)=/
619
+ nil
620
+ when telemetry_equals_regex
550
621
  nil
551
622
  when /\A--preset=/
552
623
  nil
@@ -565,7 +636,8 @@ module Harnex
565
636
  arg.start_with?(
566
637
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
567
638
  "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
568
- "--cwd=", "--root=", "--timeout=", "--inbox-ttl="
639
+ "--artifact-report=", "--validation-report=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
640
+ *TELEMETRY_EQUALS_PREFIXES
569
641
  )
570
642
  end
571
643
 
@@ -594,6 +666,32 @@ module Harnex
594
666
  raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
595
667
  end
596
668
 
669
+ def apply_telemetry_options!
670
+ explicit = @options[:telemetry]
671
+ return if explicit.empty? && @options[:meta].is_a?(Hash)
672
+ return if explicit.empty?
673
+
674
+ @options[:meta] = (@options[:meta].is_a?(Hash) ? @options[:meta].dup : {}).merge(explicit)
675
+ end
676
+
677
+ def validate_required_attribution!
678
+ return unless @options[:require_attribution]
679
+
680
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
681
+ missing = %w[project_id phase intent].select { |key| blank_value?(metadata[key]) }
682
+ unless %w[queue_id entry_id issue plan].any? { |key| !blank_value?(metadata[key]) }
683
+ missing << "one of queue_id/entry_id/issue/plan"
684
+ end
685
+ return if missing.empty?
686
+
687
+ raise OptionParser::InvalidOption,
688
+ "harnex run: --require-attribution missing #{missing.join(', ')}"
689
+ end
690
+
691
+ def blank_value?(value)
692
+ value.nil? || value.to_s.strip.empty?
693
+ end
694
+
597
695
  def apply_codex_service_tier(cli_name, child_args)
598
696
  return child_args unless cli_name.to_s == "codex"
599
697
  return child_args if child_service_tier_config?(child_args)
@@ -645,6 +743,15 @@ module Harnex
645
743
  File.expand_path(configured, repo_root)
646
744
  end
647
745
 
746
+ def resolve_artifact_report(repo_root)
747
+ configured = @options[:artifact_report]
748
+ return nil if configured.nil?
749
+
750
+ path = File.expand_path(configured, repo_root)
751
+ FileUtils.mkdir_p(File.dirname(path))
752
+ path
753
+ end
754
+
648
755
  def default_inbox_ttl
649
756
  value = ENV["HARNEX_INBOX_TTL"]
650
757
  return Inbox::DEFAULT_TTL.to_f if value.nil? || value.strip.empty?
@@ -16,6 +16,11 @@ module Harnex
16
16
  agent_session_id cost_usd
17
17
  ].freeze
18
18
  BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
19
+ QUEUE_FIELDS = %w[project_id queue_id entry_id entry_title issue plan phase tier intent].freeze
20
+ AGENT_FIELDS = %w[cli provider model_requested model_effective reasoning_effort service_tier adapter_transport].freeze
21
+ RELIABILITY_FIELDS = %w[
22
+ adapter_close real_disconnections stream_interruptions stalls force_resumes compactions recovered
23
+ ].freeze
19
24
  SUCCESSFUL_TURN_STATUSES = %w[completed success succeeded].freeze
20
25
  class EventCounters
21
26
  def initialize
@@ -59,10 +64,10 @@ module Harnex
59
64
  end
60
65
 
61
66
  attr_reader :repo_root, :launch_cwd, :child_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
62
- :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path,
67
+ :inbox, :description, :meta, :summary_out, :artifact_report_path, :output_log_path, :events_log_path,
63
68
  :started_at, :ended_at, :exit_code, :term_signal
64
69
 
65
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
70
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, artifact_report_path: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
66
71
  @adapter = adapter
67
72
  @command = command
68
73
  @repo_root = repo_root
@@ -76,6 +81,9 @@ module Harnex
76
81
  @meta = meta
77
82
  @summary_out = summary_out.to_s.strip
78
83
  @summary_out = nil if @summary_out.empty?
84
+ @artifact_report_path = artifact_report_path.to_s.strip
85
+ @artifact_report_path = nil if @artifact_report_path.empty?
86
+ @artifact_report_path = File.expand_path(@artifact_report_path, repo_root) if @artifact_report_path
79
87
  @registry_path = Harnex.registry_path(repo_root, @id)
80
88
  @output_log_path = Harnex.output_log_path(repo_root, @id)
81
89
  @events_log_path = Harnex.events_log_path(repo_root, @id)
@@ -772,6 +780,11 @@ module Harnex
772
780
  "HARNEX_SESSION_REPO_ROOT" => repo_root
773
781
  }
774
782
  env["HARNEX_DESCRIPTION"] = description if description
783
+ if artifact_report_path
784
+ env["HARNEX_ARTIFACT_REPORT_PATH"] = artifact_report_path
785
+ env["HARNEX_VALIDATION_REPORT_PATH"] = artifact_report_path
786
+ env["HARNEX_ARTIFACT_REPORT_SCHEMA"] = Harnex::ArtifactReport::SCHEMA
787
+ end
775
788
  env["HARNEX_SPAWNER_PANE"] = ENV["TMUX_PANE"] if ENV["TMUX_PANE"]
776
789
  env
777
790
  end
@@ -1157,11 +1170,17 @@ module Harnex
1157
1170
  end
1158
1171
 
1159
1172
  def build_summary_record
1160
- {
1173
+ record = {
1161
1174
  meta: build_summary_meta,
1162
1175
  predicted: summary_predicted_payload,
1163
- actual: build_summary_actual
1176
+ actual: build_summary_actual,
1177
+ agent: build_summary_agent,
1178
+ reliability: build_summary_reliability
1164
1179
  }
1180
+ queue = build_summary_queue
1181
+ record[:queue] = queue if queue
1182
+ record.merge!(artifact_report_summary) if artifact_report_path
1183
+ record
1165
1184
  end
1166
1185
 
1167
1186
  def build_summary_meta
@@ -1197,12 +1216,42 @@ module Harnex
1197
1216
  }.merge(summary_budget_meta)
1198
1217
  end
1199
1218
 
1219
+ def build_summary_queue
1220
+ queue = QUEUE_FIELDS.to_h { |field| [field, summary_string(meta_hash[field])] }
1221
+ return nil if queue.values.all?(&:nil?)
1222
+
1223
+ queue
1224
+ end
1225
+
1226
+ def build_summary_agent
1227
+ {
1228
+ "cli" => adapter.key,
1229
+ "provider" => summary_agent_provider,
1230
+ "model_requested" => summary_string(meta_hash["model"]),
1231
+ "model_effective" => summary_string(summary_model),
1232
+ "reasoning_effort" => summary_string(meta_hash["effort"]),
1233
+ "service_tier" => summary_service_tier,
1234
+ "adapter_transport" => adapter.transport.to_s
1235
+ }
1236
+ end
1237
+
1238
+ def build_summary_reliability
1239
+ counters = summary_event_counters
1240
+ real_disconnections = counters[:disconnections].to_i
1241
+ {
1242
+ "adapter_close" => summary_adapter_close(real_disconnections),
1243
+ "real_disconnections" => real_disconnections,
1244
+ "stream_interruptions" => real_disconnections,
1245
+ "stalls" => counters[:stalls].to_i,
1246
+ "force_resumes" => counters[:force_resumes].to_i,
1247
+ "compactions" => counters[:compactions].to_i,
1248
+ "recovered" => false
1249
+ }
1250
+ end
1251
+
1200
1252
  def build_summary_actual
1201
- counters = @event_counters.snapshot
1253
+ counters = summary_event_counters
1202
1254
  output_measurements = summary_output_measurements
1203
- if %w[disconnected boot_failure].include?(@exit_reason)
1204
- counters[:disconnections] = [counters[:disconnections], 1].max
1205
- end
1206
1255
 
1207
1256
  actual = {
1208
1257
  model: summary_model,
@@ -1288,6 +1337,52 @@ module Harnex
1288
1337
  (adapter.current_model if adapter.respond_to?(:current_model))
1289
1338
  end
1290
1339
 
1340
+ def summary_service_tier
1341
+ summary_string(meta_hash["service_tier"]) || service_tier_from_command
1342
+ end
1343
+
1344
+ def service_tier_from_command
1345
+ command.each_with_index do |arg, index|
1346
+ text = arg.to_s
1347
+ if text == "-c" || text == "--config"
1348
+ parsed = parse_service_tier_config(command[index + 1])
1349
+ return parsed if parsed
1350
+ end
1351
+ parsed = parse_service_tier_config(text)
1352
+ return parsed if parsed
1353
+ end
1354
+ nil
1355
+ end
1356
+
1357
+ def parse_service_tier_config(value)
1358
+ text = value.to_s
1359
+ match = text.match(/(?:\A|=)service_tier=\\?"?([^"\s]+)\\?"?/)
1360
+ return nil unless match
1361
+
1362
+ summary_string(match[1])
1363
+ end
1364
+
1365
+ def summary_event_counters
1366
+ counters = @event_counters.snapshot
1367
+ if %w[disconnected boot_failure].include?(@exit_reason)
1368
+ counters[:disconnections] = [counters[:disconnections], 1].max
1369
+ end
1370
+ counters
1371
+ end
1372
+
1373
+ def summary_adapter_close(real_disconnections)
1374
+ return "interrupted" if @exit_code == 124 || @term_signal
1375
+ return "lost" if %w[disconnected boot_failure].include?(@exit_reason) || real_disconnections.to_i.positive?
1376
+ return "normal" if %w[success failure].include?(@exit_reason)
1377
+
1378
+ "unknown"
1379
+ end
1380
+
1381
+ def summary_string(value)
1382
+ text = value.to_s
1383
+ text.empty? ? nil : text
1384
+ end
1385
+
1291
1386
  def summary_tool_calls(counters)
1292
1387
  @usage_summary[:tool_calls] || counters[:tool_calls]
1293
1388
  end
@@ -1297,6 +1392,21 @@ module Harnex
1297
1392
  predicted.is_a?(Hash) ? predicted : {}
1298
1393
  end
1299
1394
 
1395
+ def artifact_report_summary
1396
+ Harnex::ArtifactReport.ingest(artifact_report_path)
1397
+ rescue StandardError => e
1398
+ {
1399
+ "artifact_report" => {
1400
+ "path" => artifact_report_path,
1401
+ "bytes" => nil,
1402
+ "sha256" => nil,
1403
+ "ingest_status" => "error",
1404
+ "schema" => nil,
1405
+ "warning" => "artifact report ingest failed: #{e.message}"
1406
+ }
1407
+ }
1408
+ end
1409
+
1300
1410
  def meta_hash
1301
1411
  meta.is_a?(Hash) ? meta : {}
1302
1412
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.9"
2
+ VERSION = "0.7.11"
3
3
  RELEASE_DATE = "2026-07-10"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "harnex/version"
6
6
  require_relative "harnex/core"
7
7
  require_relative "harnex/dispatch_history"
8
8
  require_relative "harnex/terminal_status"
9
+ require_relative "harnex/artifact_report"
9
10
  require_relative "harnex/watcher"
10
11
  require_relative "harnex/adapters"
11
12
  require_relative "harnex/runtime/session_state"
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.7.9
4
+ version: 0.7.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
@@ -40,6 +40,7 @@ files:
40
40
  - lib/harnex/adapters/generic.rb
41
41
  - lib/harnex/adapters/opencode.rb
42
42
  - lib/harnex/adapters/pi.rb
43
+ - lib/harnex/artifact_report.rb
43
44
  - lib/harnex/cli.rb
44
45
  - lib/harnex/codex/app_server/client.rb
45
46
  - lib/harnex/commands/agents_guide.rb