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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +39 -3
- data/guides/01_dispatch.md +30 -0
- data/lib/harnex/artifact_report.rb +221 -0
- data/lib/harnex/commands/run.rb +77 -8
- data/lib/harnex/core.rb +1 -1
- data/lib/harnex/runtime/session.rb +35 -7
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2af8afe53ccea3fb668e920809da3fdbcd523ee5eef0179bcdd84f026077cff1
|
|
4
|
+
data.tar.gz: f675d4a401f703bf52adc88ce352fec04d2090533c3249bd0ac05e6828d030f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
232
|
-
|
|
233
|
-
|
|
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,
|
data/guides/01_dispatch.md
CHANGED
|
@@ -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
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -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 =
|
|
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",
|
|
202
|
+
system("tmux", "new-window", "-c", tmux_start_cwd, "-n", window_name, "-d", shell_cmd)
|
|
187
203
|
else
|
|
188
|
-
system("tmux", "new-session", "-c",
|
|
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:
|
|
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
|
-
|
|
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
|
data/lib/harnex/version.rb
CHANGED
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.
|
|
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-
|
|
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
|