harnex 0.7.13 → 0.7.14

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.
@@ -1,4 +1,5 @@
1
1
  require "digest"
2
+ require "fileutils"
2
3
  require "json"
3
4
 
4
5
  module Harnex
@@ -10,64 +11,496 @@ module Harnex
10
11
  MAX_CANONICAL_ARTIFACTS = 50
11
12
  MAX_EVIDENCE_ITEMS = 20
12
13
  MAX_STRING_LENGTH = 2_000
14
+ MAX_DIAGNOSTICS = 100
15
+
16
+ REPORT_STATUSES = %w[in_progress pass fail blocked unknown].freeze
17
+ VALIDATION_STATUSES = %w[in_progress pass fail not_run unknown].freeze
18
+ OUTCOME_STATUSES = %w[accepted rejected no_change unknown].freeze
19
+ ACCEPTED_OUTCOME_STATUSES = %w[accepted no_change].freeze
20
+
21
+ ValidationResult = Struct.new(
22
+ :ok, :status, :path, :bytes, :sha256, :schema, :diagnostics, :report,
23
+ keyword_init: true
24
+ ) do
25
+ def public_payload(final: false)
26
+ {
27
+ "ok" => !!ok,
28
+ "status" => status,
29
+ "path" => path,
30
+ "bytes" => bytes,
31
+ "sha256" => sha256,
32
+ "schema" => schema,
33
+ "final" => !!final,
34
+ "diagnostics" => diagnostics || []
35
+ }
36
+ end
37
+ end
13
38
 
14
39
  module_function
15
40
 
16
- def ingest(path)
41
+ def validate(path, final: false)
17
42
  report_path = File.expand_path(path.to_s)
18
- return missing(report_path) unless File.file?(report_path)
43
+ return validation_failure(
44
+ report_path,
45
+ status: "missing",
46
+ diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
47
+ ) unless File.file?(report_path)
19
48
 
20
49
  bytes = File.size(report_path)
21
50
  sha256 = file_sha256(report_path)
22
51
  if bytes > MAX_BYTES
23
- return warning(
52
+ return validation_failure(
24
53
  report_path,
54
+ status: "oversized",
25
55
  bytes: bytes,
26
56
  sha256: sha256,
27
- ingest_status: "oversized",
28
- warning: "artifact report is #{bytes} bytes; max is #{MAX_BYTES} bytes"
57
+ diagnostics: [
58
+ diagnostic("report_oversized", "$", "artifact report exceeds the #{MAX_BYTES}-byte limit")
59
+ ]
29
60
  )
30
61
  end
31
62
 
32
63
  parsed = JSON.parse(File.read(report_path, mode: "rb"))
33
64
  unless parsed.is_a?(Hash)
34
- return warning(
65
+ return validation_failure(
35
66
  report_path,
67
+ status: "malformed",
36
68
  bytes: bytes,
37
69
  sha256: sha256,
38
- ingest_status: "malformed",
39
- warning: "artifact report must be a JSON object"
70
+ diagnostics: [diagnostic("object_required", "$", "artifact report must be a JSON object")]
40
71
  )
41
72
  end
42
73
 
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(
74
+ schema = parsed["schema"].is_a?(String) ? bounded_string(parsed["schema"]) : nil
75
+ diagnostics = validate_document(parsed, final: final)
76
+ status = if parsed["schema"] != SCHEMA
77
+ "unsupported_schema"
78
+ elsif diagnostics.empty?
79
+ "valid"
80
+ else
81
+ "invalid"
82
+ end
83
+
84
+ ValidationResult.new(
85
+ ok: diagnostics.empty?,
86
+ status: status,
87
+ path: report_path,
88
+ bytes: bytes,
89
+ sha256: sha256,
90
+ schema: schema,
91
+ diagnostics: diagnostics,
92
+ report: parsed
93
+ )
94
+ rescue JSON::ParserError
95
+ validation_failure(
58
96
  report_path,
97
+ status: "malformed",
59
98
  bytes: safe_file_size(report_path),
60
99
  sha256: safe_file_sha256(report_path),
61
- ingest_status: "malformed",
62
- warning: "malformed artifact report JSON: #{bounded_string(e.message)}"
100
+ diagnostics: [diagnostic("json_invalid", "$", "artifact report is not valid JSON")]
63
101
  )
64
- rescue StandardError => e
65
- warning(
102
+ rescue StandardError
103
+ validation_failure(
66
104
  report_path,
105
+ status: "error",
67
106
  bytes: safe_file_size(report_path),
68
107
  sha256: safe_file_sha256(report_path),
69
- ingest_status: "error",
70
- warning: "artifact report ingest failed: #{bounded_string(e.message)}"
108
+ diagnostics: [diagnostic("read_error", "$", "artifact report could not be read")]
109
+ )
110
+ end
111
+
112
+ def ingest(path)
113
+ result = validate(path)
114
+ case result.status
115
+ when "missing"
116
+ missing(result.path)
117
+ when "oversized"
118
+ warning(
119
+ result.path,
120
+ bytes: result.bytes,
121
+ sha256: result.sha256,
122
+ ingest_status: "oversized",
123
+ warning: "artifact report is #{result.bytes} bytes; max is #{MAX_BYTES} bytes",
124
+ diagnostics: result.diagnostics
125
+ )
126
+ when "malformed"
127
+ warning(
128
+ result.path,
129
+ bytes: result.bytes,
130
+ sha256: result.sha256,
131
+ ingest_status: "malformed",
132
+ warning: "malformed artifact report JSON",
133
+ diagnostics: result.diagnostics
134
+ )
135
+ when "unsupported_schema"
136
+ warning(
137
+ result.path,
138
+ bytes: result.bytes,
139
+ sha256: result.sha256,
140
+ ingest_status: "unsupported_schema",
141
+ schema: result.schema,
142
+ warning: "unsupported artifact report schema; expected #{SCHEMA}",
143
+ diagnostics: result.diagnostics
144
+ )
145
+ when "error"
146
+ warning(
147
+ result.path,
148
+ bytes: result.bytes,
149
+ sha256: result.sha256,
150
+ ingest_status: "error",
151
+ warning: "artifact report ingest failed",
152
+ diagnostics: result.diagnostics
153
+ )
154
+ else
155
+ payload = build_payload(
156
+ result.path,
157
+ bytes: result.bytes,
158
+ sha256: result.sha256,
159
+ report: result.report
160
+ )
161
+ unless result.ok
162
+ metadata = payload.fetch("artifact_report")
163
+ metadata["ingest_status"] = "invalid"
164
+ metadata["warning"] = "artifact report does not conform to #{SCHEMA}"
165
+ metadata["diagnostics"] = result.diagnostics
166
+ end
167
+ payload
168
+ end
169
+ end
170
+
171
+ def initialize_file(path, force: false)
172
+ report_path = File.expand_path(path.to_s)
173
+ raise ArgumentError, "artifact report path is required" if path.to_s.strip.empty?
174
+ if File.exist?(report_path) && !force
175
+ raise ArgumentError, "artifact report already exists: #{report_path} (use --force to replace it)"
176
+ end
177
+
178
+ FileUtils.mkdir_p(File.dirname(report_path))
179
+ File.write(report_path, JSON.pretty_generate(template) + "\n")
180
+ report_path
181
+ end
182
+
183
+ def template
184
+ {
185
+ "schema" => SCHEMA,
186
+ "status" => "in_progress",
187
+ "canonical_artifacts" => [],
188
+ "outcome" => {
189
+ "status" => "unknown",
190
+ "summary" => ""
191
+ },
192
+ "validation" => {
193
+ "status" => "not_run",
194
+ "commands" => [],
195
+ "final_reported" => false
196
+ },
197
+ "artifacts" => []
198
+ }
199
+ end
200
+
201
+ def accepted_final?(result)
202
+ result.ok && ACCEPTED_OUTCOME_STATUSES.include?(outcome_status(result))
203
+ end
204
+
205
+ def fingerprint(path)
206
+ report_path = File.expand_path(path.to_s)
207
+ return nil unless File.file?(report_path)
208
+
209
+ stat = File.stat(report_path)
210
+ {
211
+ "bytes" => stat.size,
212
+ "sha256" => file_sha256(report_path),
213
+ "mtime_ns" => (stat.mtime.to_r * 1_000_000_000).to_i,
214
+ "ctime_ns" => (stat.ctime.to_r * 1_000_000_000).to_i,
215
+ "inode" => stat.ino
216
+ }
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ def outcome_status(result)
222
+ report = result.respond_to?(:report) ? result.report : result
223
+ return nil unless report.is_a?(Hash)
224
+
225
+ outcome = report["outcome"]
226
+ outcome.is_a?(Hash) ? outcome["status"] : nil
227
+ end
228
+
229
+ def validate_document(report, final: false)
230
+ diagnostics = []
231
+ validate_schema(report, diagnostics)
232
+ validate_optional_enum(report, "status", REPORT_STATUSES, diagnostics)
233
+ validate_string_array_field(
234
+ report,
235
+ "canonical_artifacts",
236
+ max_items: MAX_CANONICAL_ARTIFACTS,
237
+ diagnostics: diagnostics
238
+ )
239
+ validate_outcome(report["outcome"], diagnostics) if report.key?("outcome")
240
+ validate_validation(report["validation"], diagnostics) if report.key?("validation")
241
+ validate_artifacts(report["artifacts"], diagnostics) if report.key?("artifacts")
242
+ validate_final_contract(report, diagnostics) if final
243
+ diagnostics.first(MAX_DIAGNOSTICS)
244
+ end
245
+
246
+ def validate_schema(report, diagnostics)
247
+ value = report["schema"]
248
+ if !value.is_a?(String) || value.empty?
249
+ diagnostics << diagnostic("schema_required", "$.schema", "schema must be a non-empty string")
250
+ elsif value != SCHEMA
251
+ diagnostics << diagnostic("schema_unsupported", "$.schema", "schema must equal #{SCHEMA}")
252
+ end
253
+ end
254
+
255
+ def validate_optional_enum(report, key, values, diagnostics)
256
+ return unless report.key?(key)
257
+
258
+ value = report[key]
259
+ unless value.is_a?(String) && values.include?(value)
260
+ diagnostics << diagnostic("enum", "$.#{key}", "#{key} must be one of #{values.join(', ')}")
261
+ end
262
+ end
263
+
264
+ def validate_outcome(value, diagnostics)
265
+ unless value.is_a?(Hash)
266
+ diagnostics << diagnostic("object_required", "$.outcome", "outcome must be an object")
267
+ return
268
+ end
269
+
270
+ status = value["status"]
271
+ unless status.is_a?(String) && OUTCOME_STATUSES.include?(status)
272
+ diagnostics << diagnostic(
273
+ "enum",
274
+ "$.outcome.status",
275
+ "outcome.status must be one of #{OUTCOME_STATUSES.join(', ')}"
276
+ )
277
+ end
278
+ validate_optional_string(value, "summary", "$.outcome.summary", diagnostics)
279
+ validate_optional_string(value, "commit_sha", "$.outcome.commit_sha", diagnostics)
280
+ end
281
+
282
+ def validate_validation(value, diagnostics)
283
+ unless value.is_a?(Hash)
284
+ diagnostics << diagnostic("object_required", "$.validation", "validation must be an object")
285
+ return
286
+ end
287
+
288
+ if value.key?("status")
289
+ status = value["status"]
290
+ unless status.is_a?(String) && VALIDATION_STATUSES.include?(status)
291
+ diagnostics << diagnostic(
292
+ "enum",
293
+ "$.validation.status",
294
+ "validation.status must be one of #{VALIDATION_STATUSES.join(', ')}"
295
+ )
296
+ end
297
+ end
298
+
299
+ validate_commands(value["commands"], diagnostics) if value.key?("commands")
300
+ if value.key?("final_reported") && !boolean?(value["final_reported"])
301
+ diagnostics << diagnostic(
302
+ "boolean_required",
303
+ "$.validation.final_reported",
304
+ "validation.final_reported must be true or false"
305
+ )
306
+ end
307
+ end
308
+
309
+ def validate_commands(value, diagnostics)
310
+ unless value.is_a?(Array)
311
+ diagnostics << diagnostic("array_required", "$.validation.commands", "validation.commands must be an array")
312
+ return
313
+ end
314
+ if value.length > MAX_COMMANDS
315
+ diagnostics << diagnostic(
316
+ "too_many_items",
317
+ "$.validation.commands",
318
+ "validation.commands may contain at most #{MAX_COMMANDS} entries"
319
+ )
320
+ end
321
+
322
+ value.first(MAX_COMMANDS).each_with_index do |entry, index|
323
+ path = "$.validation.commands[#{index}]"
324
+ unless entry.is_a?(Hash)
325
+ diagnostics << diagnostic("object_required", path, "validation command must be an object")
326
+ next
327
+ end
328
+
329
+ validate_required_string(entry, "cmd", "#{path}.cmd", diagnostics)
330
+ unless entry["exit_code"].is_a?(Integer)
331
+ diagnostics << diagnostic(
332
+ "integer_required",
333
+ "#{path}.exit_code",
334
+ "validation command exit_code must be an integer"
335
+ )
336
+ end
337
+ validate_optional_string(entry, "status", "#{path}.status", diagnostics)
338
+ if entry.key?("duration_s") && !finite_non_negative_number?(entry["duration_s"])
339
+ diagnostics << diagnostic(
340
+ "number_required",
341
+ "#{path}.duration_s",
342
+ "validation command duration_s must be a finite non-negative number"
343
+ )
344
+ end
345
+ end
346
+ end
347
+
348
+ def validate_artifacts(value, diagnostics)
349
+ unless value.is_a?(Array)
350
+ diagnostics << diagnostic("array_required", "$.artifacts", "artifacts must be an array")
351
+ return
352
+ end
353
+ if value.length > MAX_ARTIFACTS
354
+ diagnostics << diagnostic(
355
+ "too_many_items",
356
+ "$.artifacts",
357
+ "artifacts may contain at most #{MAX_ARTIFACTS} entries"
358
+ )
359
+ end
360
+
361
+ value.first(MAX_ARTIFACTS).each_with_index do |entry, index|
362
+ path = "$.artifacts[#{index}]"
363
+ unless entry.is_a?(Hash)
364
+ diagnostics << diagnostic("object_required", path, "artifact must be an object")
365
+ next
366
+ end
367
+
368
+ validate_required_string(entry, "type", "#{path}.type", diagnostics)
369
+ validate_required_string(entry, "summary", "#{path}.summary", diagnostics)
370
+ validate_string_array_value(
371
+ entry["evidence"],
372
+ "#{path}.evidence",
373
+ max_items: MAX_EVIDENCE_ITEMS,
374
+ diagnostics: diagnostics,
375
+ required: true
376
+ )
377
+ confidence = entry["confidence"]
378
+ unless finite_number?(confidence) && confidence >= 0.0 && confidence <= 1.0
379
+ diagnostics << diagnostic(
380
+ "range",
381
+ "#{path}.confidence",
382
+ "artifact confidence must be a finite number from 0 through 1"
383
+ )
384
+ end
385
+ validate_optional_string(entry, "canonical_ref", "#{path}.canonical_ref", diagnostics)
386
+ end
387
+ end
388
+
389
+ def validate_final_contract(report, diagnostics)
390
+ unless report["status"] == "pass"
391
+ diagnostics << diagnostic("final_status", "$.status", "final report status must be pass")
392
+ end
393
+
394
+ outcome = report["outcome"]
395
+ unless outcome.is_a?(Hash)
396
+ diagnostics << diagnostic("required", "$.outcome", "final report requires an outcome object")
397
+ else
398
+ unless ACCEPTED_OUTCOME_STATUSES.include?(outcome["status"])
399
+ diagnostics << diagnostic(
400
+ "outcome_not_accepted",
401
+ "$.outcome.status",
402
+ "final outcome must be accepted or no_change"
403
+ )
404
+ end
405
+ unless non_empty_string?(outcome["summary"])
406
+ diagnostics << diagnostic(
407
+ "required",
408
+ "$.outcome.summary",
409
+ "final outcome requires a non-empty summary"
410
+ )
411
+ end
412
+ end
413
+
414
+ validation = report["validation"]
415
+ unless validation.is_a?(Hash)
416
+ diagnostics << diagnostic("required", "$.validation", "final report requires a validation object")
417
+ return
418
+ end
419
+
420
+ expected_statuses = outcome.is_a?(Hash) && outcome["status"] == "no_change" ? %w[pass not_run] : %w[pass]
421
+ unless expected_statuses.include?(validation["status"])
422
+ diagnostics << diagnostic(
423
+ "final_validation_status",
424
+ "$.validation.status",
425
+ "final validation.status must be #{expected_statuses.join(' or ')}"
426
+ )
427
+ end
428
+ unless validation["commands"].is_a?(Array)
429
+ diagnostics << diagnostic(
430
+ "required",
431
+ "$.validation.commands",
432
+ "final report requires a validation.commands array"
433
+ )
434
+ end
435
+ unless validation["final_reported"] == true
436
+ diagnostics << diagnostic(
437
+ "final_reported",
438
+ "$.validation.final_reported",
439
+ "final report requires validation.final_reported=true"
440
+ )
441
+ end
442
+
443
+ Array(validation["commands"]).first(MAX_COMMANDS).each_with_index do |entry, index|
444
+ next unless entry.is_a?(Hash) && entry["exit_code"].is_a?(Integer)
445
+ next if entry["exit_code"].zero?
446
+
447
+ diagnostics << diagnostic(
448
+ "command_failed",
449
+ "$.validation.commands[#{index}].exit_code",
450
+ "accepted final reports require validation command exit_code=0"
451
+ )
452
+ end
453
+ end
454
+
455
+ def validate_string_array_field(report, key, max_items:, diagnostics:)
456
+ return unless report.key?(key)
457
+
458
+ validate_string_array_value(
459
+ report[key],
460
+ "$.#{key}",
461
+ max_items: max_items,
462
+ diagnostics: diagnostics,
463
+ required: true
464
+ )
465
+ end
466
+
467
+ def validate_string_array_value(value, path, max_items:, diagnostics:, required:)
468
+ if value.nil? && !required
469
+ return
470
+ end
471
+ unless value.is_a?(Array)
472
+ diagnostics << diagnostic("array_required", path, "#{path.delete_prefix('$.')} must be an array")
473
+ return
474
+ end
475
+ if value.length > max_items
476
+ diagnostics << diagnostic("too_many_items", path, "#{path.delete_prefix('$.')} may contain at most #{max_items} entries")
477
+ end
478
+
479
+ value.first(max_items).each_with_index do |item, index|
480
+ next if non_empty_string?(item)
481
+
482
+ diagnostics << diagnostic(
483
+ "string_required",
484
+ "#{path}[#{index}]",
485
+ "#{path.delete_prefix('$.')} entries must be non-empty strings"
486
+ )
487
+ end
488
+ end
489
+
490
+ def validate_required_string(hash, key, path, diagnostics)
491
+ return if non_empty_string?(hash[key])
492
+
493
+ diagnostics << diagnostic("string_required", path, "#{path.delete_prefix('$.')} must be a non-empty string")
494
+ end
495
+
496
+ def validate_optional_string(hash, key, path, diagnostics)
497
+ return unless hash.key?(key)
498
+ return if hash[key].is_a?(String) && hash[key].length <= MAX_STRING_LENGTH
499
+
500
+ diagnostics << diagnostic(
501
+ "string_required",
502
+ path,
503
+ "#{path.delete_prefix('$.')} must be a string no longer than #{MAX_STRING_LENGTH} characters"
71
504
  )
72
505
  end
73
506
 
@@ -100,13 +533,15 @@ module Harnex
100
533
  bytes: nil,
101
534
  sha256: nil,
102
535
  ingest_status: "missing",
103
- warning: "artifact report not found"
536
+ warning: "artifact report not found",
537
+ diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
104
538
  )
105
539
  end
106
540
 
107
- def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil)
541
+ def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil, diagnostics: nil)
108
542
  report = metadata(path, bytes: bytes, sha256: sha256, ingest_status: ingest_status, schema: schema)
109
543
  report["warning"] = bounded_string(warning)
544
+ report["diagnostics"] = Array(diagnostics).first(MAX_DIAGNOSTICS) unless Array(diagnostics).empty?
110
545
  { "artifact_report" => report }
111
546
  end
112
547
 
@@ -127,7 +562,7 @@ module Harnex
127
562
  payload["status"] = bounded_string_or_nil(value["status"])
128
563
  payload["commands"] = compact_commands(value["commands"])
129
564
  payload["final_reported"] = !!value["final_reported"] if value.key?("final_reported")
130
- payload.delete_if { |_key, v| v.nil? || v == [] }
565
+ payload.delete_if { |_key, item| item.nil? || item == [] }
131
566
  payload.empty? ? nil : payload
132
567
  end
133
568
 
@@ -141,7 +576,7 @@ module Harnex
141
576
  "status" => bounded_string_or_nil(entry["status"]),
142
577
  "duration_s" => finite_float_or_nil(entry["duration_s"])
143
578
  }
144
- compact.delete_if { |_key, v| v.nil? }
579
+ compact.delete_if { |_key, item| item.nil? }
145
580
  compact.empty? ? nil : compact
146
581
  end
147
582
  end
@@ -150,7 +585,7 @@ module Harnex
150
585
  return nil unless value.is_a?(Hash)
151
586
 
152
587
  status = value["status"].to_s
153
- return nil unless %w[accepted rejected no_change unknown].include?(status)
588
+ return nil unless OUTCOME_STATUSES.include?(status)
154
589
 
155
590
  payload = {
156
591
  "status" => status,
@@ -172,11 +607,32 @@ module Harnex
172
607
  "confidence" => finite_float_or_nil(entry["confidence"]),
173
608
  "canonical_ref" => bounded_string_or_nil(entry["canonical_ref"])
174
609
  }
175
- compact.delete_if { |_key, v| v.nil? || v == [] }
610
+ compact.delete_if { |_key, item| item.nil? || item == [] }
176
611
  compact.empty? ? nil : compact
177
612
  end
178
613
  end
179
614
 
615
+ def validation_failure(path, status:, diagnostics:, bytes: nil, sha256: nil)
616
+ ValidationResult.new(
617
+ ok: false,
618
+ status: status,
619
+ path: path,
620
+ bytes: bytes,
621
+ sha256: sha256,
622
+ schema: nil,
623
+ diagnostics: diagnostics.first(MAX_DIAGNOSTICS),
624
+ report: nil
625
+ )
626
+ end
627
+
628
+ def diagnostic(code, path, message)
629
+ {
630
+ "code" => code,
631
+ "path" => path,
632
+ "message" => bounded_string(message)
633
+ }
634
+ end
635
+
180
636
  def string_array(value, max_items:)
181
637
  Array(value).first(max_items).filter_map do |item|
182
638
  text = bounded_string_or_nil(item)
@@ -214,6 +670,22 @@ module Harnex
214
670
  nil
215
671
  end
216
672
 
673
+ def finite_number?(value)
674
+ value.is_a?(Numeric) && value.finite?
675
+ end
676
+
677
+ def finite_non_negative_number?(value)
678
+ finite_number?(value) && value >= 0
679
+ end
680
+
681
+ def non_empty_string?(value)
682
+ value.is_a?(String) && !value.empty? && value.length <= MAX_STRING_LENGTH
683
+ end
684
+
685
+ def boolean?(value)
686
+ value == true || value == false
687
+ end
688
+
217
689
  def file_sha256(path)
218
690
  digest = Digest::SHA256.new
219
691
  File.open(path, "rb") do |file|
data/lib/harnex/cli.rb CHANGED
@@ -37,6 +37,10 @@ module Harnex
37
37
  AgentsGuide.new(@argv.drop(1)).run
38
38
  when "doctor"
39
39
  Doctor.new(@argv.drop(1)).run
40
+ when "orchestration"
41
+ OrchestrationCommand.new(@argv.drop(1)).run
42
+ when "artifact-report"
43
+ ArtifactReportCommand.new(@argv.drop(1)).run
40
44
  when "help"
41
45
  puts help(@argv[1])
42
46
  0
@@ -83,6 +87,10 @@ module Harnex
83
87
  AgentsGuide.usage
84
88
  when "doctor"
85
89
  Doctor.usage
90
+ when "orchestration"
91
+ OrchestrationCommand.usage
92
+ when "artifact-report"
93
+ ArtifactReportCommand.usage
86
94
  else
87
95
  usage
88
96
  end
@@ -103,6 +111,8 @@ module Harnex
103
111
  harnex pane --id ID [options]
104
112
  harnex agents-guide [topic]
105
113
  harnex doctor
114
+ harnex orchestration sample|report [options]
115
+ harnex artifact-report init|validate PATH [options]
106
116
  harnex help [command]
107
117
 
108
118
  Commands:
@@ -121,6 +131,10 @@ module Harnex
121
131
  agents-guide
122
132
  Show agent dispatch, chain, buddy, monitoring, and naming guides
123
133
  doctor Run preflight checks and optional read-only session sweep
134
+ orchestration
135
+ Emit external primary samples and report orchestration tax
136
+ artifact-report
137
+ Initialize or validate harnex.artifact_report.v1 sidecars
124
138
  help Show command help
125
139
 
126
140
  New to harnex? Start with: harnex guide
@@ -142,6 +156,9 @@ module Harnex
142
156
  harnex pane --id main --lines 40
143
157
  harnex agents-guide dispatch
144
158
  harnex doctor
159
+ harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
160
+ harnex artifact-report init .harnex/reports/cx-i-60.json
161
+ harnex artifact-report validate .harnex/reports/cx-i-60.json --final
145
162
  harnex send --id main --message "Summarize current progress."
146
163
  TEXT
147
164
  end