harnex 0.7.8 → 0.7.10

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: 64a1ddf83ef070cc2b418c1a70ae3e1fc2c8b5fd671e8fa3e5a30536184728ca
4
- data.tar.gz: 6a076faed04db3eddbaf4bf2fefbee95426cd8ee8008499e3474fbfa1b5c62ed
3
+ metadata.gz: 2af8afe53ccea3fb668e920809da3fdbcd523ee5eef0179bcdd84f026077cff1
4
+ data.tar.gz: f675d4a401f703bf52adc88ce352fec04d2090533c3249bd0ac05e6828d030f0
5
5
  SHA512:
6
- metadata.gz: 3508afcbddc0e9afaf17372ca98cb3146d5edc128fb54c6217f8b80be7f81baae0ab7f50feba4b513a9ba46165323f8fb95ddd243df5bd19921b198beb89bbc6
7
- data.tar.gz: 25419186825ac14d2f6cd844497d6dc356e88785f23784f30492284b80c384781ab834011f394d27c93078821fc394fed3ac97b8bff818f7b60fc280be83a560
6
+ metadata.gz: cda7d84fd1f80c9526529b03293623eb16f76a78872e5e29640896be76f79a448ab3bf8f4c92c74c56b8f88b814739b7fbd201a6c91ba749d22ecdc5cf8a720e
7
+ data.tar.gz: bc6704fc2a86a01e61c1a431fd96781bbaf178029679bc1b5c64be699f82c49bbf8b27d4f921c0d7547752eadc2dc61cffc1bc946b99d0cd5d79dd46a0de77af
data/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.10] - 2026-07-10 | 10:57 PM | IST
6
+
7
+ ### Added
8
+
9
+ - `harnex run --artifact-report PATH` / `--validation-report PATH` now expose a
10
+ worker-writable `harnex.artifact_report.v1` sidecar path via
11
+ `HARNEX_ARTIFACT_REPORT_PATH` and `HARNEX_VALIDATION_REPORT_PATH`, ingest the
12
+ report at dispatch finalization, and append compact top-level
13
+ `artifact_report`, `validation`, and `artifacts` blocks to the dispatch
14
+ summary row.
15
+
16
+ ### Changed
17
+
18
+ - Dispatch telemetry docs, README, and the packaged dispatch agents-guide now
19
+ document sidecar proof while keeping plain-text `koder/` artifacts canonical.
20
+
21
+ ### Fixed
22
+
23
+ - Missing, malformed, unsupported-schema, and oversized artifact reports now
24
+ fail soft with `artifact_report.ingest_status` warning telemetry instead of
25
+ crashing or changing the wrapped process exit code.
26
+
27
+ ## [0.7.9] - 2026-07-10 | 10:33 PM | IST
28
+
29
+ ### Added
30
+
31
+ - `harnex run` now accepts wrapper-level `--cwd DIR` and `--root DIR` options.
32
+ `--cwd` starts the wrapped agent in `DIR` and makes that directory the
33
+ harnex session root for registry/session metadata and default dispatch
34
+ summaries. `--root` changes harnex root attribution without changing the
35
+ child process cwd.
36
+
37
+ ### Changed
38
+
39
+ - `harnex run --help`, README, and the dispatch guide now document
40
+ public-bundle / temporary-workdir dispatches with `--cwd`.
41
+ - Refreshed pinned Codex app-server schema fixtures against
42
+ `codex-cli 0.144.1` so the release suite is green with the current local
43
+ Codex CLI.
44
+
45
+ ### Fixed
46
+
47
+ - Non-git temporary work directories no longer leak raw `git rev-parse` fatal
48
+ messages during harnex root probing.
49
+
5
50
  ## [0.7.8] - 2026-06-13 | 08:45 PM | IST
6
51
 
7
52
  ### Added
data/README.md CHANGED
@@ -57,6 +57,24 @@ harnex stop --id planner
57
57
  That's the core loop. Start a fresh agent for each step, hand it one
58
58
  job, watch it work, stop it when done.
59
59
 
60
+ ### Run from a temporary/public bundle
61
+
62
+ Use `--cwd DIR` when the worker should see a specific directory rather than
63
+ the orchestrator's current repo. Harnex starts the wrapped agent in `DIR`, sets
64
+ that directory as the session root, and resolves default telemetry such as
65
+ `.harnex/dispatch.jsonl` there:
66
+
67
+ ```bash
68
+ harnex run codex --cwd /tmp/leximaze_eval_run_001 \
69
+ --id lm-run-001 \
70
+ --context "Read README.md and write RESPONSES.jsonl and OUTPUT.md" \
71
+ --auto-stop
72
+ ```
73
+
74
+ Use `--root DIR` only when you need to override harnex's root attribution
75
+ without changing the child process cwd. `--cwd` is not a security sandbox; it is
76
+ an explicit working-directory/root selector for automation.
77
+
60
78
  ## Why use this
61
79
 
62
80
  - **You want agents to plan, implement, review, and fix — in sequence.**
@@ -228,9 +246,11 @@ Schema details and compatibility policy are documented in
228
246
 
229
247
  ## Dispatch history
230
248
 
231
- Every finished `harnex run` writes dispatch records. In a git repo, the
232
- default path is `<repo>/.harnex/dispatch.jsonl`; outside a git repo, the
233
- compact history record falls back to `~/.local/state/harnex/dispatch.jsonl`.
249
+ Every finished `harnex run` writes dispatch records. By default, the terminal
250
+ summary JSONL path is `<session-root>/.harnex/dispatch.jsonl`; `--cwd DIR` makes
251
+ `DIR` the session root, including for non-git temporary bundles. The compact
252
+ history record is repo-local in a git tree and falls back to
253
+ `~/.local/state/harnex/dispatch.jsonl` outside git.
234
254
  `harnex history` reads the compact records from that location, and
235
255
  `harnex status --id ID --json` / `harnex wait` can use the same durable
236
256
  terminal summaries when the live session registry is already gone.
@@ -248,6 +268,22 @@ Dispatch briefs can declare soft budget metadata through `--meta`:
248
268
  harnex run pi --meta '{"read_budget_lines":2000,"output_ceiling_lines":800}' ...
249
269
  ```
250
270
 
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
+
251
287
  Those declared values are copied into summary `meta`. Terminal summary
252
288
  `actual` records timing, exit classification, token usage when the adapter can
253
289
  capture it, adapter-reported `cost_usd` when reliably available, git deltas,
@@ -81,6 +81,36 @@ Rule: when you use `--tmux`, pass the same name as `--id`. If you pass only
81
81
  `--tmux NAME`, harnex creates a random session ID and the pane name no longer
82
82
  matches `harnex status` or `harnex pane --id`.
83
83
 
84
+ For public-bundle or benchmark runs, use `--cwd DIR` to make harnex launch the
85
+ wrapped agent from that directory and associate session metadata/default
86
+ telemetry with it:
87
+
88
+ ```bash
89
+ harnex run codex --cwd /tmp/leximaze_eval_run_001 \
90
+ --id lm-run-001 \
91
+ --context "Read README.md and write RESPONSES.jsonl and OUTPUT.md" \
92
+ --auto-stop
93
+ ```
94
+
95
+ `--root DIR` only overrides harnex's root attribution; it does not change the
96
+ child process cwd. Neither flag is a sandbox.
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
+
84
114
  Pi runs use structured RPC (`pi --mode rpc`). Pass Pi child flags after `--`
85
115
  (e.g. `harnex run pi --context "..." -- --model anthropic/claude-sonnet-4-5 --thinking high`).
86
116
 
@@ -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
@@ -8,11 +8,11 @@ module Harnex
8
8
  KNOWN_FLAGS = %w[
9
9
  --id --description --detach --tmux --host --port --watch --watch-file
10
10
  --stall-after --max-resumes --preset --context --meta --summary-out
11
- --timeout --inbox-ttl --auto-stop --fast --legacy-pty --help
11
+ --artifact-report --validation-report --cwd --root --timeout --inbox-ttl --auto-stop --fast --legacy-pty --help
12
12
  ].freeze
13
13
  VALUE_FLAGS = %w[
14
14
  --id --description --host --port --watch --watch-file --stall-after
15
- --max-resumes --preset --context --meta --summary-out --timeout --inbox-ttl
15
+ --max-resumes --preset --context --meta --summary-out --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
16
16
  ].freeze
17
17
 
18
18
  def self.usage(program_name = "harnex run")
@@ -37,6 +37,12 @@ module Harnex
37
37
  Default Codex runs force service_tier="flex".
38
38
  --meta JSON Attach parsed JSON metadata to the started event
39
39
  --summary-out PATH Append dispatch telemetry summary JSONL to PATH
40
+ --artifact-report PATH
41
+ Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
42
+ --validation-report PATH
43
+ Alias for --artifact-report; also exposed as HARNEX_VALIDATION_REPORT_PATH
44
+ --cwd DIR Run the wrapped agent from DIR and use DIR as the session root
45
+ --root DIR Override harnex session/root attribution without changing child cwd
40
46
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
41
47
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
42
48
  --legacy-pty (codex only) Use the legacy PTY adapter instead of
@@ -58,6 +64,8 @@ module Harnex
58
64
  #{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md"
59
65
  #{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
60
66
  #{program_name} pi --id pi-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
67
+ #{program_name} codex --cwd /tmp/public-bundle --id eval-001 --context "Read README.md and write OUTPUT.md" --auto-stop
68
+ #{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
61
69
  #{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
62
70
 
63
71
  Gotchas:
@@ -88,6 +96,9 @@ module Harnex
88
96
  context: nil,
89
97
  meta: nil,
90
98
  summary_out: nil,
99
+ artifact_report: nil,
100
+ cwd: nil,
101
+ root: nil,
91
102
  auto_stop: false,
92
103
  detach: false,
93
104
  tmux: false,
@@ -110,8 +121,9 @@ module Harnex
110
121
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
111
122
  validate_auto_stop_context!
112
123
 
113
- repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
124
+ repo_root = resolve_run_root(cli_name, child_args)
114
125
  @options[:summary_out] = resolve_summary_out(repo_root)
126
+ @options[:artifact_report] = resolve_artifact_report(repo_root)
115
127
  @options[:id] ||= Harnex.generate_id(repo_root)
116
128
  validate_unique_id!(repo_root)
117
129
  effective_child_args = apply_context(apply_codex_service_tier(cli_name, child_args))
@@ -173,6 +185,9 @@ module Harnex
173
185
  tmux_cmd << "--auto-stop" if @options[:auto_stop]
174
186
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
175
187
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
188
+ tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
189
+ tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
190
+ tmux_cmd += ["--root", @options[:root]] if @options[:root]
176
191
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
177
192
  tmux_cmd << "--fast" if @options[:fast]
178
193
  tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
@@ -181,11 +196,12 @@ module Harnex
181
196
  window_name = @options[:tmux_name] || @options[:id]
182
197
  shell_cmd = tmux_cmd.map { |arg| Shellwords.shellescape(arg) }.join(" ")
183
198
 
199
+ tmux_start_cwd = @options[:cwd] || @launch_cwd
184
200
  started =
185
201
  if ENV["TMUX"]
186
- system("tmux", "new-window", "-c", @launch_cwd, "-n", window_name, "-d", shell_cmd)
202
+ system("tmux", "new-window", "-c", tmux_start_cwd, "-n", window_name, "-d", shell_cmd)
187
203
  else
188
- system("tmux", "new-session", "-c", @launch_cwd, "-d", "-s", "harnex", "-n", window_name, shell_cmd)
204
+ system("tmux", "new-session", "-c", tmux_start_cwd, "-d", "-s", "harnex", "-n", window_name, shell_cmd)
189
205
  end
190
206
 
191
207
  raise "tmux failed to start #{cli_name.inspect}" unless started
@@ -279,9 +295,11 @@ module Harnex
279
295
  description: @options[:description],
280
296
  meta: @options[:meta],
281
297
  summary_out: @options[:summary_out],
298
+ artifact_report_path: @options[:artifact_report],
282
299
  inbox_ttl: @options[:inbox_ttl],
283
300
  auto_stop: @options[:auto_stop],
284
- launch_cwd: @launch_cwd
301
+ launch_cwd: history_cwd,
302
+ child_cwd: session_child_cwd
285
303
  )
286
304
  end
287
305
 
@@ -289,6 +307,24 @@ module Harnex
289
307
  Harnex.build_adapter(cli_name, child_args, legacy_pty: @options[:legacy_pty]).infer_repo_path(child_args)
290
308
  end
291
309
 
310
+ def resolve_run_root(cli_name, child_args)
311
+ return @options[:root] if @options[:root]
312
+ return @options[:cwd] if @options[:cwd]
313
+
314
+ Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
315
+ end
316
+
317
+ def history_cwd
318
+ @options[:root] || @options[:cwd] || @launch_cwd
319
+ end
320
+
321
+ def session_child_cwd
322
+ return @options[:cwd] if @options[:cwd]
323
+ return @launch_cwd if @options[:root]
324
+
325
+ nil
326
+ end
327
+
292
328
  def apply_context(child_args)
293
329
  return child_args unless @options[:context]
294
330
 
@@ -441,6 +477,23 @@ module Harnex
441
477
  @options[:summary_out] = required_option_value(arg, argv[index])
442
478
  when /\A--summary-out=(.+)\z/
443
479
  @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
480
+ when "--artifact-report", "--validation-report"
481
+ index += 1
482
+ @options[:artifact_report] = required_option_value(arg, argv[index])
483
+ when /\A--artifact-report=(.+)\z/
484
+ @options[:artifact_report] = required_option_value("--artifact-report", Regexp.last_match(1))
485
+ when /\A--validation-report=(.+)\z/
486
+ @options[:artifact_report] = required_option_value("--validation-report", Regexp.last_match(1))
487
+ when "--cwd"
488
+ index += 1
489
+ @options[:cwd] = expand_existing_directory(required_option_value(arg, argv[index]), option_name: arg)
490
+ when /\A--cwd=(.+)\z/
491
+ @options[:cwd] = expand_existing_directory(required_option_value("--cwd", Regexp.last_match(1)), option_name: "--cwd")
492
+ when "--root"
493
+ index += 1
494
+ @options[:root] = expand_existing_directory(required_option_value(arg, argv[index]), option_name: arg)
495
+ when /\A--root=(.+)\z/
496
+ @options[:root] = expand_existing_directory(required_option_value("--root", Regexp.last_match(1)), option_name: "--root")
444
497
  when "--timeout"
445
498
  index += 1
446
499
  @options[:timeout] = Float(required_option_value(arg, argv[index]))
@@ -509,7 +562,7 @@ module Harnex
509
562
  nil
510
563
  when *VALUE_FLAGS
511
564
  index += 1
512
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|timeout|inbox-ttl)=/
565
+ 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)=/
513
566
  nil
514
567
  when /\A--preset=/
515
568
  nil
@@ -528,7 +581,7 @@ module Harnex
528
581
  arg.start_with?(
529
582
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
530
583
  "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
531
- "--timeout=", "--inbox-ttl="
584
+ "--artifact-report=", "--validation-report=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl="
532
585
  )
533
586
  end
534
587
 
@@ -585,6 +638,13 @@ module Harnex
585
638
  raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
586
639
  end
587
640
 
641
+ def expand_existing_directory(value, option_name:)
642
+ path = File.expand_path(value.to_s, @launch_cwd)
643
+ return path if File.directory?(path)
644
+
645
+ raise OptionParser::InvalidArgument, "#{option_name} must be an existing directory: #{value}"
646
+ end
647
+
588
648
  def parse_meta(value)
589
649
  parsed = JSON.parse(value)
590
650
  return parsed if parsed.is_a?(Hash)
@@ -601,6 +661,15 @@ module Harnex
601
661
  File.expand_path(configured, repo_root)
602
662
  end
603
663
 
664
+ def resolve_artifact_report(repo_root)
665
+ configured = @options[:artifact_report]
666
+ return nil if configured.nil?
667
+
668
+ path = File.expand_path(configured, repo_root)
669
+ FileUtils.mkdir_p(File.dirname(path))
670
+ path
671
+ end
672
+
604
673
  def default_inbox_ttl
605
674
  value = ENV["HARNEX_INBOX_TTL"]
606
675
  return Inbox::DEFAULT_TTL.to_f if value.nil? || value.strip.empty?
data/lib/harnex/core.rb CHANGED
@@ -32,7 +32,7 @@ module Harnex
32
32
  ].freeze
33
33
 
34
34
  def resolve_repo_root(path = Dir.pwd)
35
- output, status = Open3.capture2("git", "rev-parse", "--show-toplevel", chdir: path)
35
+ output, status = Open3.capture2("git", "rev-parse", "--show-toplevel", chdir: path, err: File::NULL)
36
36
  status.success? ? output.strip : File.expand_path(path)
37
37
  rescue StandardError
38
38
  File.expand_path(path)
@@ -58,15 +58,16 @@ module Harnex
58
58
  end
59
59
  end
60
60
 
61
- attr_reader :repo_root, :launch_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
62
- :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path,
61
+ attr_reader :repo_root, :launch_cwd, :child_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
62
+ :inbox, :description, :meta, :summary_out, :artifact_report_path, :output_log_path, :events_log_path,
63
63
  :started_at, :ended_at, :exit_code, :term_signal
64
64
 
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)
65
+ 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
66
  @adapter = adapter
67
67
  @command = command
68
68
  @repo_root = repo_root
69
69
  @launch_cwd = File.expand_path(launch_cwd.to_s.empty? ? repo_root : launch_cwd)
70
+ @child_cwd = child_cwd.to_s.empty? ? nil : File.expand_path(child_cwd)
70
71
  @host = host
71
72
  @id = Harnex.normalize_id(id)
72
73
  @watch = watch
@@ -75,6 +76,9 @@ module Harnex
75
76
  @meta = meta
76
77
  @summary_out = summary_out.to_s.strip
77
78
  @summary_out = nil if @summary_out.empty?
79
+ @artifact_report_path = artifact_report_path.to_s.strip
80
+ @artifact_report_path = nil if @artifact_report_path.empty?
81
+ @artifact_report_path = File.expand_path(@artifact_report_path, repo_root) if @artifact_report_path
78
82
  @registry_path = Harnex.registry_path(repo_root, @id)
79
83
  @output_log_path = Harnex.output_log_path(repo_root, @id)
80
84
  @events_log_path = Harnex.events_log_path(repo_root, @id)
@@ -153,7 +157,9 @@ module Harnex
153
157
  end
154
158
 
155
159
  def run_pty
156
- @reader, @writer, @pid = PTY.spawn(child_env, *command)
160
+ spawn_args = [child_env, *command]
161
+ spawn_args << { chdir: child_cwd } if child_cwd
162
+ @reader, @writer, @pid = PTY.spawn(*spawn_args)
157
163
  @writer.sync = true
158
164
  arm_auto_stop_after_initial_context
159
165
  emit_started_event
@@ -402,7 +408,7 @@ module Harnex
402
408
  adapter.on_notification { |msg| handle_structured_notification(msg) }
403
409
  adapter.on_disconnect { |err| handle_structured_disconnect(err) }
404
410
 
405
- adapter.start_rpc(env: child_env, cwd: repo_root)
411
+ adapter.start_rpc(env: child_env, cwd: child_cwd || repo_root)
406
412
  @pid = adapter.pid
407
413
  @state_machine.force_prompt!
408
414
  emit_started_event
@@ -769,6 +775,11 @@ module Harnex
769
775
  "HARNEX_SESSION_REPO_ROOT" => repo_root
770
776
  }
771
777
  env["HARNEX_DESCRIPTION"] = description if description
778
+ if artifact_report_path
779
+ env["HARNEX_ARTIFACT_REPORT_PATH"] = artifact_report_path
780
+ env["HARNEX_VALIDATION_REPORT_PATH"] = artifact_report_path
781
+ env["HARNEX_ARTIFACT_REPORT_SCHEMA"] = Harnex::ArtifactReport::SCHEMA
782
+ end
772
783
  env["HARNEX_SPAWNER_PANE"] = ENV["TMUX_PANE"] if ENV["TMUX_PANE"]
773
784
  env
774
785
  end
@@ -821,7 +832,7 @@ module Harnex
821
832
  def registry_payload
822
833
  status_payload(include_input_state: false).merge(
823
834
  token: token,
824
- cwd: Dir.pwd
835
+ cwd: child_cwd || Dir.pwd
825
836
  )
826
837
  end
827
838
 
@@ -1154,11 +1165,13 @@ module Harnex
1154
1165
  end
1155
1166
 
1156
1167
  def build_summary_record
1157
- {
1168
+ record = {
1158
1169
  meta: build_summary_meta,
1159
1170
  predicted: summary_predicted_payload,
1160
1171
  actual: build_summary_actual
1161
1172
  }
1173
+ record.merge!(artifact_report_summary) if artifact_report_path
1174
+ record
1162
1175
  end
1163
1176
 
1164
1177
  def build_summary_meta
@@ -1294,6 +1307,21 @@ module Harnex
1294
1307
  predicted.is_a?(Hash) ? predicted : {}
1295
1308
  end
1296
1309
 
1310
+ def artifact_report_summary
1311
+ Harnex::ArtifactReport.ingest(artifact_report_path)
1312
+ rescue StandardError => e
1313
+ {
1314
+ "artifact_report" => {
1315
+ "path" => artifact_report_path,
1316
+ "bytes" => nil,
1317
+ "sha256" => nil,
1318
+ "ingest_status" => "error",
1319
+ "schema" => nil,
1320
+ "warning" => "artifact report ingest failed: #{e.message}"
1321
+ }
1322
+ }
1323
+ end
1324
+
1297
1325
  def meta_hash
1298
1326
  meta.is_a?(Hash) ? meta : {}
1299
1327
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.8"
3
- RELEASE_DATE = "2026-06-13"
2
+ VERSION = "0.7.10"
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,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.8
4
+ version: 0.7.10
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-06-13 00:00:00.000000000 Z
11
+ date: 2026-07-10 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.
@@ -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