harnex 0.9.0 → 0.10.0

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,6 +1,7 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
3
  require "json"
4
+ require "time"
4
5
 
5
6
  module Harnex
6
7
  module ArtifactReport
@@ -12,6 +13,12 @@ module Harnex
12
13
  MAX_EVIDENCE_ITEMS = 20
13
14
  MAX_STRING_LENGTH = 2_000
14
15
  MAX_DIAGNOSTICS = 100
16
+ MAX_CHANGED_PATHS = 200
17
+ RECEIPT_VERSION = 1
18
+ CLAIM_SEVERITIES = %w[P1 P2 P3].freeze
19
+ COMMAND_OBSERVATION_STATUSES = %w[observed unsupported].freeze
20
+ GIT_OBSERVATION_STATUSES = %w[observed unavailable].freeze
21
+ USAGE_STATUSES = %w[observed estimated unsupported missing zero].freeze
15
22
 
16
23
  REPORT_STATUSES = %w[in_progress pass fail blocked unknown].freeze
17
24
  VALIDATION_STATUSES = %w[in_progress pass fail not_run unknown].freeze
@@ -198,6 +205,159 @@ module Harnex
198
205
  }
199
206
  end
200
207
 
208
+ # Default receipts live outside the checkout so proof generation cannot
209
+ # become part of the Git delta it is recording. An explicit
210
+ # --artifact-report path still wins for callers that need a stable path.
211
+ def default_path(repo_root:, id:, session_id:)
212
+ slug = Harnex.id_key(id)
213
+ slug = Digest::SHA256.hexdigest(id.to_s)[0, 12] if slug.empty?
214
+ File.join(
215
+ Harnex::STATE_DIR,
216
+ "receipts",
217
+ "#{Harnex.repo_key(repo_root)}-#{slug}-#{session_id}.json"
218
+ )
219
+ end
220
+
221
+ def claims_path(report_path)
222
+ "#{File.expand_path(report_path.to_s)}.claims.json"
223
+ end
224
+
225
+ # Claims are deliberately advisory. Malformed, stale, or oversized claims
226
+ # are ignored by Session and can never invalidate observed proof.
227
+ def extract_claims(path)
228
+ claims_path = File.expand_path(path.to_s)
229
+ return {} unless File.file?(claims_path)
230
+ return {} if File.size(claims_path) > MAX_BYTES
231
+
232
+ document = JSON.parse(File.read(claims_path, mode: "rb"))
233
+ return {} unless document.is_a?(Hash)
234
+
235
+ source = document["claims"].is_a?(Hash) ? document["claims"] : document
236
+ claims = compact_claims(source)
237
+
238
+ # Compatibility bridge for old worker-authored v1 reports. Their
239
+ # outcome is retained only as an untrusted verdict/summary claim; it no
240
+ # longer participates in completion acceptance.
241
+ if claims.empty? && document["schema"] == SCHEMA
242
+ outcome = document["outcome"]
243
+ if outcome.is_a?(Hash)
244
+ claims = compact_claims(
245
+ "summary" => outcome["summary"],
246
+ "verdict" => outcome["status"]
247
+ )
248
+ end
249
+ end
250
+ claims
251
+ rescue StandardError
252
+ {}
253
+ end
254
+
255
+ def build_observed(
256
+ id:, session_id:, generated_at:, successful:, outcome_status:,
257
+ outcome_summary:, git:, commands:, turn:, usage:, claims:,
258
+ command_observation:
259
+ )
260
+ observed_commands = compact_commands(commands).select do |entry|
261
+ non_empty_string?(entry["cmd"]) && entry["exit_code"].is_a?(Integer)
262
+ end
263
+ git_payload = compact_observed_git(git)
264
+ turn_payload = compact_observed_turn(turn)
265
+ usage_payload = compact_observed_usage(usage)
266
+ claims_payload = compact_claims(claims)
267
+ validation_status = if observed_commands.empty?
268
+ "not_run"
269
+ elsif observed_commands.all? { |entry| entry["exit_code"].zero? }
270
+ "pass"
271
+ else
272
+ "fail"
273
+ end
274
+ start_sha = git_payload["start_sha"].to_s
275
+ end_sha = git_payload["end_sha"].to_s
276
+ commit_sha = end_sha unless start_sha.empty? || end_sha.empty? || start_sha == end_sha
277
+
278
+ report = {
279
+ "schema" => SCHEMA,
280
+ "status" => successful ? "pass" : "fail",
281
+ "canonical_artifacts" => [],
282
+ "outcome" => {
283
+ "status" => outcome_status,
284
+ "summary" => bounded_string(outcome_summary),
285
+ "commit_sha" => commit_sha
286
+ }.compact,
287
+ "validation" => {
288
+ "status" => validation_status,
289
+ "commands" => observed_commands,
290
+ "final_reported" => true
291
+ },
292
+ "artifacts" => [],
293
+ "receipt" => {
294
+ "version" => RECEIPT_VERSION,
295
+ "author" => "harnex",
296
+ "generated_at" => generated_at.respond_to?(:iso8601) ? generated_at.iso8601 : generated_at.to_s,
297
+ "id" => bounded_string(id),
298
+ "session_id" => bounded_string(session_id)
299
+ },
300
+ "observed" => {
301
+ "git" => git_payload,
302
+ "commands" => observed_commands,
303
+ "command_observation" => command_observation.to_s,
304
+ "turn" => turn_payload,
305
+ "usage" => usage_payload
306
+ }
307
+ }
308
+ report["claims"] = claims_payload unless claims_payload.empty?
309
+ fit_observed_report!(report)
310
+ end
311
+
312
+ def fit_observed_report!(report)
313
+ loop do
314
+ return report if JSON.pretty_generate(report).bytesize + 1 <= MAX_BYTES
315
+
316
+ commands = report.dig("observed", "commands")
317
+ if commands.is_a?(Array) && !commands.empty?
318
+ commands.pop
319
+ report.dig("observed")["commands_truncated"] = true
320
+ next
321
+ end
322
+
323
+ paths = report.dig("observed", "git", "changed_paths")
324
+ if paths.is_a?(Array) && !paths.empty?
325
+ paths.pop
326
+ report.dig("observed", "git")["changed_paths_truncated"] = true
327
+ next
328
+ end
329
+
330
+ raise ArgumentError, "generated artifact receipt exceeds the #{MAX_BYTES}-byte limit"
331
+ end
332
+ end
333
+
334
+ def write_observed(path, **attributes)
335
+ report_path = File.expand_path(path.to_s)
336
+ report = build_observed(**attributes)
337
+ diagnostics = validate_document(report)
338
+ unless diagnostics.empty?
339
+ raise ArgumentError, "generated artifact receipt is invalid: #{diagnostics.first.fetch('path')}"
340
+ end
341
+
342
+ FileUtils.mkdir_p(File.dirname(report_path))
343
+ temporary_path = "#{report_path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
344
+ File.open(temporary_path, "wb", 0o644) do |file|
345
+ file.write(JSON.pretty_generate(report))
346
+ file.write("\n")
347
+ file.flush
348
+ file.fsync
349
+ end
350
+ File.rename(temporary_path, report_path)
351
+
352
+ result = validate(report_path, final: !!attributes[:successful])
353
+ if attributes[:successful] && !accepted_final?(result)
354
+ raise ArgumentError, "generated artifact receipt did not satisfy final validation"
355
+ end
356
+ result
357
+ ensure
358
+ FileUtils.rm_f(temporary_path) if defined?(temporary_path) && temporary_path
359
+ end
360
+
201
361
  def accepted_final?(result)
202
362
  result.ok && ACCEPTED_OUTCOME_STATUSES.include?(outcome_status(result))
203
363
  end
@@ -239,6 +399,10 @@ module Harnex
239
399
  validate_outcome(report["outcome"], diagnostics) if report.key?("outcome")
240
400
  validate_validation(report["validation"], diagnostics) if report.key?("validation")
241
401
  validate_artifacts(report["artifacts"], diagnostics) if report.key?("artifacts")
402
+ validate_receipt(report["receipt"], diagnostics) if report.key?("receipt")
403
+ validate_observed(report["observed"], diagnostics) if report.key?("observed")
404
+ # `claims` is intentionally not part of validity. Session emits only a
405
+ # bounded sanitized subset, while malformed worker claims are ignored.
242
406
  validate_final_contract(report, diagnostics) if final
243
407
  diagnostics.first(MAX_DIAGNOSTICS)
244
408
  end
@@ -306,21 +470,21 @@ module Harnex
306
470
  end
307
471
  end
308
472
 
309
- def validate_commands(value, diagnostics)
473
+ def validate_commands(value, diagnostics, base_path: "$.validation.commands")
310
474
  unless value.is_a?(Array)
311
- diagnostics << diagnostic("array_required", "$.validation.commands", "validation.commands must be an array")
475
+ diagnostics << diagnostic("array_required", base_path, "#{base_path.delete_prefix('$.')} must be an array")
312
476
  return
313
477
  end
314
478
  if value.length > MAX_COMMANDS
315
479
  diagnostics << diagnostic(
316
480
  "too_many_items",
317
- "$.validation.commands",
318
- "validation.commands may contain at most #{MAX_COMMANDS} entries"
481
+ base_path,
482
+ "#{base_path.delete_prefix('$.')} may contain at most #{MAX_COMMANDS} entries"
319
483
  )
320
484
  end
321
485
 
322
486
  value.first(MAX_COMMANDS).each_with_index do |entry, index|
323
- path = "$.validation.commands[#{index}]"
487
+ path = "#{base_path}[#{index}]"
324
488
  unless entry.is_a?(Hash)
325
489
  diagnostics << diagnostic("object_required", path, "validation command must be an object")
326
490
  next
@@ -386,7 +550,119 @@ module Harnex
386
550
  end
387
551
  end
388
552
 
553
+ def validate_receipt(value, diagnostics)
554
+ unless value.is_a?(Hash)
555
+ diagnostics << diagnostic("object_required", "$.receipt", "receipt must be an object")
556
+ return
557
+ end
558
+
559
+ unless value["version"] == RECEIPT_VERSION
560
+ diagnostics << diagnostic("receipt_version", "$.receipt.version", "receipt.version must equal #{RECEIPT_VERSION}")
561
+ end
562
+ unless value["author"] == "harnex"
563
+ diagnostics << diagnostic("receipt_author", "$.receipt.author", "receipt.author must equal harnex")
564
+ end
565
+ validate_required_string(value, "generated_at", "$.receipt.generated_at", diagnostics)
566
+ begin
567
+ Time.iso8601(value["generated_at"].to_s)
568
+ rescue ArgumentError
569
+ diagnostics << diagnostic("timestamp_required", "$.receipt.generated_at", "receipt.generated_at must be ISO 8601")
570
+ end
571
+ validate_required_string(value, "id", "$.receipt.id", diagnostics)
572
+ validate_required_string(value, "session_id", "$.receipt.session_id", diagnostics)
573
+ end
574
+
575
+ def validate_observed(value, diagnostics)
576
+ unless value.is_a?(Hash)
577
+ diagnostics << diagnostic("object_required", "$.observed", "observed must be an object")
578
+ return
579
+ end
580
+
581
+ git = value["git"]
582
+ unless git.is_a?(Hash)
583
+ diagnostics << diagnostic("object_required", "$.observed.git", "observed.git must be an object")
584
+ else
585
+ unless GIT_OBSERVATION_STATUSES.include?(git["status"])
586
+ diagnostics << diagnostic("enum", "$.observed.git.status", "observed.git.status must be observed or unavailable")
587
+ end
588
+ %w[start_sha end_sha branch].each do |key|
589
+ validate_optional_string(git, key, "$.observed.git.#{key}", diagnostics)
590
+ end
591
+ if git["status"] == "observed"
592
+ %w[start_sha end_sha].each do |key|
593
+ unless git[key].is_a?(String) && git[key].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
594
+ diagnostics << diagnostic("git_sha", "$.observed.git.#{key}", "observed.git.#{key} must be a full hexadecimal Git SHA")
595
+ end
596
+ end
597
+ end
598
+ validate_string_array_value(
599
+ git["changed_paths"],
600
+ "$.observed.git.changed_paths",
601
+ max_items: MAX_CHANGED_PATHS,
602
+ diagnostics: diagnostics,
603
+ required: true
604
+ ) if git["status"] == "observed" || git.key?("changed_paths")
605
+ %w[loc_added loc_removed files_changed commits].each do |key|
606
+ if git["status"] == "observed" && !git.key?(key)
607
+ diagnostics << diagnostic("required", "$.observed.git.#{key}", "observed.git.#{key} is required when Git is observed")
608
+ next
609
+ end
610
+ next unless git.key?(key)
611
+ next if git[key].is_a?(Integer) && git[key] >= 0
612
+
613
+ diagnostics << diagnostic("integer_required", "$.observed.git.#{key}", "observed.git.#{key} must be a non-negative integer")
614
+ end
615
+ %w[start_dirty end_dirty worktree_changed changed_paths_truncated].each do |key|
616
+ next unless git.key?(key)
617
+ next if boolean?(git[key])
618
+
619
+ diagnostics << diagnostic("boolean_required", "$.observed.git.#{key}", "observed.git.#{key} must be true or false")
620
+ end
621
+ end
622
+
623
+ validate_commands(value["commands"], diagnostics, base_path: "$.observed.commands")
624
+ if value.key?("commands_truncated") && !boolean?(value["commands_truncated"])
625
+ diagnostics << diagnostic("boolean_required", "$.observed.commands_truncated", "observed.commands_truncated must be true or false")
626
+ end
627
+ unless COMMAND_OBSERVATION_STATUSES.include?(value["command_observation"])
628
+ diagnostics << diagnostic(
629
+ "enum",
630
+ "$.observed.command_observation",
631
+ "observed.command_observation must be observed or unsupported"
632
+ )
633
+ end
634
+
635
+ turn = value["turn"]
636
+ unless turn.is_a?(Hash)
637
+ diagnostics << diagnostic("object_required", "$.observed.turn", "observed.turn must be an object")
638
+ else
639
+ validate_required_string(turn, "status", "$.observed.turn.status", diagnostics)
640
+ %w[task_complete task_failed accepted].each do |key|
641
+ next if boolean?(turn[key])
642
+
643
+ diagnostics << diagnostic("boolean_required", "$.observed.turn.#{key}", "observed.turn.#{key} must be true or false")
644
+ end
645
+ validate_optional_string(turn, "outcome_class", "$.observed.turn.outcome_class", diagnostics)
646
+ if turn.key?("exit_code") && !turn["exit_code"].is_a?(Integer)
647
+ diagnostics << diagnostic("integer_required", "$.observed.turn.exit_code", "observed.turn.exit_code must be an integer")
648
+ end
649
+ end
650
+
651
+ unless value["usage"].is_a?(Hash)
652
+ diagnostics << diagnostic("object_required", "$.observed.usage", "observed.usage must be an object")
653
+ else
654
+ unless USAGE_STATUSES.include?(value.dig("usage", "status"))
655
+ diagnostics << diagnostic("enum", "$.observed.usage.status", "observed.usage.status must be observed, estimated, unsupported, missing, or zero")
656
+ end
657
+ end
658
+ end
659
+
389
660
  def validate_final_contract(report, diagnostics)
661
+ if harness_receipt?(report)
662
+ validate_observed_final_contract(report, diagnostics)
663
+ return
664
+ end
665
+
390
666
  unless report["status"] == "pass"
391
667
  diagnostics << diagnostic("final_status", "$.status", "final report status must be pass")
392
668
  end
@@ -452,6 +728,55 @@ module Harnex
452
728
  end
453
729
  end
454
730
 
731
+ def harness_receipt?(report)
732
+ report.dig("receipt", "author") == "harnex"
733
+ end
734
+
735
+ # Harness receipts prove that the receipt itself is complete and that
736
+ # Harnex accepted the observed terminal state. Individual command failures
737
+ # remain factual telemetry: they are not treated as worker-authored gates,
738
+ # because exploratory failures can precede a successful final turn.
739
+ def validate_observed_final_contract(report, diagnostics)
740
+ unless report["status"] == "pass"
741
+ diagnostics << diagnostic("final_status", "$.status", "final receipt status must be pass")
742
+ end
743
+
744
+ outcome = report["outcome"]
745
+ unless outcome.is_a?(Hash) && ACCEPTED_OUTCOME_STATUSES.include?(outcome["status"])
746
+ diagnostics << diagnostic("outcome_not_accepted", "$.outcome.status", "final receipt outcome must be accepted or no_change")
747
+ end
748
+ unless outcome.is_a?(Hash) && non_empty_string?(outcome["summary"])
749
+ diagnostics << diagnostic("required", "$.outcome.summary", "final receipt requires a non-empty summary")
750
+ end
751
+
752
+ validation = report["validation"]
753
+ unless validation.is_a?(Hash) && validation["final_reported"] == true
754
+ diagnostics << diagnostic("final_reported", "$.validation.final_reported", "final receipt requires validation.final_reported=true")
755
+ end
756
+
757
+ turn = report.dig("observed", "turn")
758
+ unless turn.is_a?(Hash) && turn["accepted"] == true
759
+ diagnostics << diagnostic("observed_not_accepted", "$.observed.turn.accepted", "final receipt requires an accepted observed turn")
760
+ end
761
+
762
+ return unless outcome.is_a?(Hash) && outcome["status"] == "no_change"
763
+
764
+ git = report.dig("observed", "git")
765
+ no_change = git.is_a?(Hash) &&
766
+ git["status"] == "observed" &&
767
+ git["commits"].to_i.zero? &&
768
+ Array(git["changed_paths"]).empty? &&
769
+ !git["start_sha"].to_s.empty? &&
770
+ git["start_sha"].to_s == git["end_sha"].to_s
771
+ unless no_change
772
+ diagnostics << diagnostic(
773
+ "no_change_unobserved",
774
+ "$.observed.git",
775
+ "no_change requires an observed zero-delta Git state"
776
+ )
777
+ end
778
+ end
779
+
455
780
  def validate_string_array_field(report, key, max_items:, diagnostics:)
456
781
  return unless report.key?(key)
457
782
 
@@ -508,6 +833,9 @@ module Harnex
508
833
  artifacts = compact_artifacts(report["artifacts"])
509
834
  validation = compact_validation(report["validation"])
510
835
  outcome = compact_outcome(report["outcome"])
836
+ receipt = compact_receipt(report["receipt"])
837
+ observed = compact_observed(report["observed"])
838
+ claims = compact_claims(report["claims"])
511
839
  payload = {
512
840
  "artifact_report" => metadata(
513
841
  path,
@@ -518,12 +846,16 @@ module Harnex
518
846
  ).merge(
519
847
  "report_status" => bounded_string_or_nil(report["status"]),
520
848
  "canonical_artifacts" => string_array(report["canonical_artifacts"], max_items: MAX_CANONICAL_ARTIFACTS),
521
- "artifact_count" => artifacts.length
849
+ "artifact_count" => artifacts.length,
850
+ "author" => receipt&.dig("author")
522
851
  )
523
852
  }
524
853
  payload["validation"] = validation if validation
525
854
  payload["artifacts"] = artifacts unless artifacts.empty?
526
855
  payload["outcome"] = outcome if outcome
856
+ payload["receipt"] = receipt if receipt
857
+ payload["observed"] = observed if observed
858
+ payload["claims"] = claims unless claims.empty?
527
859
  payload
528
860
  end
529
861
 
@@ -555,6 +887,107 @@ module Harnex
555
887
  }
556
888
  end
557
889
 
890
+ def compact_receipt(value)
891
+ return nil unless value.is_a?(Hash)
892
+
893
+ payload = {
894
+ "version" => integer_or_nil(value["version"]),
895
+ "author" => bounded_string_or_nil(value["author"]),
896
+ "generated_at" => bounded_string_or_nil(value["generated_at"]),
897
+ "id" => bounded_string_or_nil(value["id"]),
898
+ "session_id" => bounded_string_or_nil(value["session_id"])
899
+ }
900
+ payload.delete_if { |_key, item| item.nil? }
901
+ payload.empty? ? nil : payload
902
+ end
903
+
904
+ def compact_observed(value)
905
+ return nil unless value.is_a?(Hash)
906
+
907
+ payload = {
908
+ "git" => compact_observed_git(value["git"]),
909
+ "commands" => compact_commands(value["commands"]),
910
+ "commands_truncated" => boolean_or_nil(value["commands_truncated"]),
911
+ "command_observation" => bounded_string_or_nil(value["command_observation"]),
912
+ "turn" => compact_observed_turn(value["turn"]),
913
+ "usage" => compact_observed_usage(value["usage"])
914
+ }
915
+ payload.delete_if { |_key, item| item.nil? }
916
+ payload.empty? ? nil : payload
917
+ end
918
+
919
+ def compact_observed_git(value)
920
+ value = {} unless value.is_a?(Hash)
921
+ status = hash_value(value, "status").to_s
922
+ status = "observed" if status.empty? && !hash_value(value, "start_sha").to_s.empty?
923
+ status = "unavailable" unless GIT_OBSERVATION_STATUSES.include?(status)
924
+ payload = {
925
+ "status" => status,
926
+ "start_sha" => bounded_string_or_nil(hash_value(value, "start_sha")),
927
+ "end_sha" => bounded_string_or_nil(hash_value(value, "end_sha")),
928
+ "branch" => bounded_string_or_nil(hash_value(value, "branch")),
929
+ "changed_paths" => string_array(hash_value(value, "changed_paths"), max_items: MAX_CHANGED_PATHS),
930
+ "loc_added" => non_negative_integer_or_nil(hash_value(value, "loc_added")),
931
+ "loc_removed" => non_negative_integer_or_nil(hash_value(value, "loc_removed")),
932
+ "files_changed" => non_negative_integer_or_nil(hash_value(value, "files_changed")),
933
+ "commits" => non_negative_integer_or_nil(hash_value(value, "commits")),
934
+ "start_dirty" => boolean_or_nil(hash_value(value, "start_dirty")),
935
+ "end_dirty" => boolean_or_nil(hash_value(value, "end_dirty")),
936
+ "worktree_changed" => boolean_or_nil(hash_value(value, "worktree_changed")),
937
+ "changed_paths_truncated" => boolean_or_nil(hash_value(value, "changed_paths_truncated"))
938
+ }
939
+ payload.delete_if { |key, item| item.nil? || (key == "changed_paths" && status == "unavailable") }
940
+ payload
941
+ end
942
+
943
+ def compact_observed_turn(value)
944
+ value = {} unless value.is_a?(Hash)
945
+ payload = {
946
+ "status" => bounded_string_or_nil(hash_value(value, "status")),
947
+ "outcome_class" => bounded_string_or_nil(hash_value(value, "outcome_class")),
948
+ "task_complete" => !!hash_value(value, "task_complete"),
949
+ "task_failed" => !!hash_value(value, "task_failed"),
950
+ "accepted" => !!hash_value(value, "accepted"),
951
+ "exit_code" => integer_or_nil(hash_value(value, "exit_code")),
952
+ "signal" => integer_or_nil(hash_value(value, "signal"))
953
+ }
954
+ payload.delete_if { |_key, item| item.nil? }
955
+ payload
956
+ end
957
+
958
+ def compact_observed_usage(value)
959
+ value = {} unless value.is_a?(Hash)
960
+ payload = {}
961
+ %w[status cost_source cost_price_as_of].each do |key|
962
+ payload[key] = bounded_string_or_nil(hash_value(value, key))
963
+ end
964
+ payload["cost_usd"] = finite_float_or_nil(hash_value(value, "cost_usd"))
965
+ %w[input_tokens output_tokens cached_input_tokens reasoning_tokens total_tokens].each do |key|
966
+ payload[key] = non_negative_integer_or_nil(hash_value(value, key))
967
+ end
968
+ payload.delete_if { |_key, item| item.nil? }
969
+ payload
970
+ end
971
+
972
+ def compact_claims(value)
973
+ return {} unless value.is_a?(Hash)
974
+
975
+ payload = {
976
+ "summary" => bounded_string_or_nil(hash_value(value, "summary")),
977
+ "verdict" => bounded_string_or_nil(hash_value(value, "verdict"))
978
+ }
979
+ findings = hash_value(value, "findings")
980
+ if findings.is_a?(Hash)
981
+ counts = CLAIM_SEVERITIES.each_with_object({}) do |severity, result|
982
+ count = non_negative_integer_or_nil(hash_value(findings, severity))
983
+ result[severity] = count unless count.nil?
984
+ end
985
+ payload["findings"] = counts unless counts.empty?
986
+ end
987
+ payload.delete_if { |_key, item| item.nil? || item == "" }
988
+ payload
989
+ end
990
+
558
991
  def compact_validation(value)
559
992
  return nil unless value.is_a?(Hash)
560
993
 
@@ -661,6 +1094,18 @@ module Harnex
661
1094
  nil
662
1095
  end
663
1096
 
1097
+ def non_negative_integer_or_nil(value)
1098
+ integer = integer_or_nil(value)
1099
+ integer if integer && integer >= 0
1100
+ end
1101
+
1102
+ def hash_value(hash, key)
1103
+ return nil unless hash.is_a?(Hash)
1104
+ return hash[key] if hash.key?(key)
1105
+
1106
+ hash[key.to_sym]
1107
+ end
1108
+
664
1109
  def finite_float_or_nil(value)
665
1110
  return nil if value.nil?
666
1111
 
@@ -682,6 +1127,10 @@ module Harnex
682
1127
  value.is_a?(String) && !value.empty? && value.length <= MAX_STRING_LENGTH
683
1128
  end
684
1129
 
1130
+ def boolean_or_nil(value)
1131
+ value if boolean?(value)
1132
+ end
1133
+
685
1134
  def boolean?(value)
686
1135
  value == true || value == false
687
1136
  end
data/lib/harnex/cli.rb CHANGED
@@ -134,7 +134,7 @@ module Harnex
134
134
  orchestration
135
135
  Emit external primary samples and report orchestration tax
136
136
  artifact-report
137
- Initialize or validate harnex.artifact_report.v1 sidecars
137
+ Validate harness-authored receipts or initialize legacy v1 documents
138
138
  help Show command help
139
139
 
140
140
  New to harnex? Start with: harnex guide
@@ -157,8 +157,8 @@ module Harnex
157
157
  harnex agents-guide dispatch
158
158
  harnex doctor
159
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
160
+ harnex artifact-report validate /path/from-dispatch-row.json --final
161
+ harnex artifact-report init .harnex/reports/legacy-v1.json
162
162
  harnex send --id main --message "Summarize current progress."
163
163
  TEXT
164
164
  end
@@ -10,18 +10,19 @@ module Harnex
10
10
  #{program_name} validate PATH [--final]
11
11
 
12
12
  Commands:
13
- init Write a bounded harnex.artifact_report.v1 skeleton
14
- validate Validate schema and field shapes without printing report data
13
+ init Write a legacy/manual harnex.artifact_report.v1 skeleton
14
+ validate Validate a harness receipt or legacy document without printing data
15
15
 
16
16
  Options:
17
- --final Require an accepted/no_change final outcome, passing proof,
18
- and validation.final_reported=true
17
+ --final Require an accepted/no_change final harness receipt, or the
18
+ legacy passing-proof contract
19
19
  --force Replace an existing file during init
20
20
  -h, --help Show this help
21
21
 
22
- Both commands print machine-readable JSON. `validate` exits 0 only when
23
- the requested contract is satisfied; diagnostics contain field paths and
24
- shape errors, never report payloads or transcripts.
22
+ Normal `harnex run` sessions generate their own receipt; workers do not
23
+ need `init`. Both commands print machine-readable JSON. `validate` exits
24
+ 0 only when the requested contract is satisfied; diagnostics contain
25
+ field paths and shape errors, never report payloads or transcripts.
25
26
  TEXT
26
27
  end
27
28
 
@@ -16,7 +16,7 @@ module Harnex
16
16
 
17
17
  Options:
18
18
  --sweep Include a read-only report of harnex/tmux session drift
19
- --prune Apply bounded harnex events/output retention pruning
19
+ --prune Apply bounded harnex events/output/receipt retention pruning
20
20
  --dry-run Preview --prune candidates without deleting
21
21
  -h, --help Show this help
22
22