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 "io/console"
2
2
  require "json"
3
3
  require "pty"
4
+ require "shellwords"
4
5
 
5
6
  module Harnex
6
7
  class Session
@@ -86,10 +87,11 @@ module Harnex
86
87
  end
87
88
 
88
89
  attr_reader :repo_root, :launch_cwd, :child_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
89
- :inbox, :description, :meta, :summary_out, :artifact_report_path, :output_log_path, :events_log_path,
90
- :started_at, :ended_at, :exit_code, :term_signal, :require_artifact_report
90
+ :inbox, :description, :meta, :artifact_report_path, :artifact_claims_path,
91
+ :output_log_path, :events_log_path, :started_at, :ended_at, :exit_code, :term_signal,
92
+ :require_artifact_report
91
93
 
92
- 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, require_artifact_report: false, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
94
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, artifact_report_path: nil, require_artifact_report: false, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
93
95
  @adapter = adapter
94
96
  @command = command
95
97
  @repo_root = repo_root
@@ -97,22 +99,29 @@ module Harnex
97
99
  @child_cwd = child_cwd.to_s.empty? ? nil : File.expand_path(child_cwd)
98
100
  @host = host
99
101
  @id = Harnex.normalize_id(id)
102
+ @session_id = SecureRandom.hex(8)
100
103
  @watch = watch
101
104
  @description = description.to_s.strip
102
105
  @description = nil if @description.empty?
103
106
  @meta = meta
104
- @summary_out = summary_out.to_s.strip
105
- @summary_out = nil if @summary_out.empty?
106
- @artifact_report_path = artifact_report_path.to_s.strip
107
- @artifact_report_path = nil if @artifact_report_path.empty?
108
- @artifact_report_path = File.expand_path(@artifact_report_path, repo_root) if @artifact_report_path
107
+ configured_report_path = artifact_report_path.to_s.strip
108
+ @artifact_report_path = if configured_report_path.empty?
109
+ Harnex::ArtifactReport.default_path(
110
+ repo_root: repo_root,
111
+ id: @id,
112
+ session_id: @session_id
113
+ )
114
+ else
115
+ File.expand_path(configured_report_path, repo_root)
116
+ end
117
+ @artifact_claims_path = Harnex::ArtifactReport.claims_path(@artifact_report_path)
118
+ FileUtils.mkdir_p(File.dirname(@artifact_report_path))
109
119
  @require_artifact_report = !!require_artifact_report
110
- raise ArgumentError, "require_artifact_report requires artifact_report_path" if @require_artifact_report && !@artifact_report_path
111
- @artifact_report_start_fingerprint = Harnex::ArtifactReport.fingerprint(@artifact_report_path) if @artifact_report_path
120
+ @artifact_report_start_fingerprint = Harnex::ArtifactReport.fingerprint(@artifact_report_path)
121
+ @artifact_claims_start_fingerprint = Harnex::ArtifactReport.fingerprint(@artifact_claims_path)
112
122
  @registry_path = Harnex.registry_path(repo_root, @id)
113
123
  @output_log_path = Harnex.output_log_path(repo_root, @id)
114
124
  @events_log_path = Harnex.events_log_path(repo_root, @id)
115
- @session_id = SecureRandom.hex(8)
116
125
  @token = SecureRandom.hex(16)
117
126
  @port = Harnex.allocate_port(repo_root, @id, port, host: host)
118
127
  @mutex = Mutex.new
@@ -129,6 +138,12 @@ module Harnex
129
138
  @events_log = nil
130
139
  @events_log_seq = 0
131
140
  @event_counters = EventCounters.new
141
+ @observed_commands_mutex = Mutex.new
142
+ @observed_commands = []
143
+ @artifact_claims = {}
144
+ @artifact_claims_loaded = false
145
+ @artifact_report_generated = false
146
+ @artifact_report_write_error = nil
132
147
  @git_start = {}
133
148
  @git_end = {}
134
149
  @usage_summary = {}
@@ -257,7 +272,9 @@ module Harnex
257
272
  last_injected_at: @last_injected_at&.iso8601,
258
273
  injected_count: @injected_count,
259
274
  output_log_path: output_log_path,
260
- events_log_path: events_log_path
275
+ events_log_path: events_log_path,
276
+ artifact_report_path: artifact_report_path,
277
+ artifact_claims_path: artifact_claims_path
261
278
  }
262
279
  payload.merge!(log_activity_snapshot)
263
280
  payload[:description] = description if description
@@ -427,7 +444,7 @@ module Harnex
427
444
  @state_machine.force_busy!
428
445
  @injected_count += 1
429
446
  @last_injected_at = Time.now
430
- persist_registry
447
+ refresh_registry
431
448
  end
432
449
 
433
450
  emit_send_event(dispatch.fetch(:prompt, text), force: payload[:force])
@@ -571,6 +588,7 @@ module Harnex
571
588
  when "item/completed"
572
589
  emit_event("item_completed", item: params["item"])
573
590
  @event_counters.record_item(params["item"])
591
+ record_observed_command(params["item"])
574
592
  text = render_item_text(params["item"])
575
593
  record_synthesized(text) if text
576
594
  when "thread/compacted"
@@ -628,6 +646,7 @@ module Harnex
628
646
  artifact_report_status: assessment[:report_status],
629
647
  diagnostics: assessment[:diagnostics]
630
648
  )
649
+ enforce_required_artifact_report!
631
650
  return false
632
651
  end
633
652
 
@@ -641,9 +660,15 @@ module Harnex
641
660
  @completion_diagnostics = []
642
661
  end
643
662
 
663
+ # Persist proof before publishing task_complete. A coordinator that sees
664
+ # the completion event can immediately validate the harness-authored file.
665
+ enforce_required_artifact_report!
666
+ return false if task_failed?
667
+
644
668
  event_payload = payload.dup
645
669
  event_payload[:outcome_class] = @completion_outcome_class if @completion_outcome_class
646
670
  event_payload[:artifact_report_status] = @completion_report_status if @completion_report_status
671
+ event_payload[:artifact_report_path] = artifact_report_path
647
672
  emit_event("task_complete", **event_payload)
648
673
  true
649
674
  end
@@ -656,85 +681,30 @@ module Harnex
656
681
  end
657
682
 
658
683
  def assess_completion_proof
659
- report_result = artifact_report_path ? Harnex::ArtifactReport.validate(artifact_report_path, final: true) : nil
660
- report_fresh = report_result && report_result.status != "missing" && artifact_report_fresh?
661
- report_stale = report_result && Harnex::ArtifactReport.accepted_final?(report_result) && !report_fresh
662
- report_status = report_stale ? "stale" : report_result&.status
663
- report_diagnostics = if report_stale
664
- [artifact_report_stale_diagnostic]
665
- else
666
- report_result&.diagnostics || []
667
- end
668
-
669
- if report_result
670
- if report_fresh && Harnex::ArtifactReport.accepted_final?(report_result)
671
- return {
672
- accepted: true,
673
- outcome_class: "completed_with_proof",
674
- report_status: "accepted",
675
- diagnostics: []
676
- }
677
- end
678
-
679
- report_outcome = Harnex::ArtifactReport.outcome_status(report_result)
680
- if report_fresh && report_outcome == "rejected"
681
- return {
682
- accepted: false,
683
- outcome_class: "report_rejected",
684
- report_status: "rejected",
685
- diagnostics: report_result.diagnostics
686
- }
687
- end
688
-
689
- if require_artifact_report
690
- outcome_class = report_result.status == "missing" ? "report_missing" : "report_invalid"
691
- return {
692
- accepted: false,
693
- outcome_class: outcome_class,
694
- report_status: report_status,
695
- diagnostics: report_diagnostics
696
- }
697
- end
698
- elsif require_artifact_report
699
- return {
700
- accepted: false,
701
- outcome_class: "report_missing",
702
- report_status: "missing",
703
- diagnostics: []
704
- }
705
- end
706
-
707
- if structured_activity_observed? || git_activity_observed?
684
+ refresh_git_end_snapshot
685
+ if structured_activity_observed? || git_activity_observed?(@git_end)
708
686
  {
709
687
  accepted: true,
710
688
  outcome_class: "completed_with_activity",
711
- report_status: report_status,
689
+ report_status: nil,
712
690
  diagnostics: []
713
691
  }
714
692
  else
715
693
  {
716
694
  accepted: false,
717
695
  outcome_class: "completed_no_activity",
718
- report_status: report_status,
719
- diagnostics: report_diagnostics
696
+ report_status: "rejected",
697
+ diagnostics: []
720
698
  }
721
699
  end
722
700
  end
723
701
 
724
- def artifact_report_fresh?
725
- current = Harnex::ArtifactReport.fingerprint(artifact_report_path)
726
- return false unless current
727
- return true unless @artifact_report_start_fingerprint
728
-
729
- current != @artifact_report_start_fingerprint
730
- end
731
-
732
- def artifact_report_stale_diagnostic
733
- {
734
- "code" => "report_stale",
735
- "path" => "$",
736
- "message" => "artifact report was not created or updated during this session"
737
- }
702
+ # Fingerprints now protect only optional claims ingestion. They are never
703
+ # an acceptance gate: Harnex always replaces the receipt with fresh
704
+ # observed state for this session.
705
+ def artifact_input_fresh?(path, start_fingerprint)
706
+ current = Harnex::ArtifactReport.fingerprint(path)
707
+ current && (!start_fingerprint || current != start_fingerprint)
738
708
  end
739
709
 
740
710
  def structured_activity_observed?
@@ -742,10 +712,10 @@ module Harnex
742
712
  counters[:commands_executed].to_i.positive? || counters[:tool_calls].to_i.positive?
743
713
  end
744
714
 
745
- def git_activity_observed?
715
+ def git_activity_observed?(snapshot = nil)
746
716
  return false if @git_start[:sha].to_s.empty?
747
717
 
748
- snapshot = Harnex.git_capture_end(repo_root, @git_start[:sha])
718
+ snapshot ||= Harnex.git_capture_end(repo_root, @git_start)
749
719
  snapshot[:commits].to_i.positive? || Array(snapshot[:changed_paths]).any? ||
750
720
  (!snapshot[:sha].to_s.empty? && snapshot[:sha].to_s != @git_start[:sha].to_s)
751
721
  end
@@ -753,7 +723,7 @@ module Harnex
753
723
  def completion_failure_message(outcome_class)
754
724
  case outcome_class
755
725
  when "completed_no_activity"
756
- "turn completed without command/tool execution, Git delta, or accepted artifact report"
726
+ "turn completed without command/tool execution or Git delta"
757
727
  when "report_missing"
758
728
  "required artifact report is missing"
759
729
  when "report_rejected"
@@ -942,6 +912,33 @@ module Harnex
942
912
  end
943
913
  end
944
914
 
915
+ def record_observed_command(item)
916
+ return unless item.is_a?(Hash) && item["type"] == "commandExecution"
917
+
918
+ command = item["command"]
919
+ command = Shellwords.join(command.map(&:to_s)) if command.is_a?(Array)
920
+ command = command.to_s
921
+ exit_code = item["exitCode"]
922
+ exit_code = item["exit_code"] if exit_code.nil?
923
+ exit_code = Integer(exit_code)
924
+ return if command.empty?
925
+
926
+ duration = item["duration_s"]
927
+ duration = item["durationMs"].to_f / 1000.0 if duration.nil? && item["durationMs"]
928
+ observed = {
929
+ "cmd" => command,
930
+ "exit_code" => exit_code,
931
+ "status" => item["status"],
932
+ "duration_s" => duration
933
+ }.compact
934
+ @observed_commands_mutex.synchronize do
935
+ @observed_commands << observed
936
+ @observed_commands = @observed_commands.last(Harnex::ArtifactReport::MAX_COMMANDS)
937
+ end
938
+ rescue ArgumentError, TypeError
939
+ nil
940
+ end
941
+
945
942
  def summarize(value)
946
943
  str = value.is_a?(String) ? value : JSON.generate(value)
947
944
  str.length > 120 ? "#{str[0, 117]}..." : str
@@ -1006,7 +1003,9 @@ module Harnex
1006
1003
  if artifact_report_path
1007
1004
  env["HARNEX_ARTIFACT_REPORT_PATH"] = artifact_report_path
1008
1005
  env["HARNEX_VALIDATION_REPORT_PATH"] = artifact_report_path
1006
+ env["HARNEX_ARTIFACT_CLAIMS_PATH"] = artifact_claims_path
1009
1007
  env["HARNEX_ARTIFACT_REPORT_SCHEMA"] = Harnex::ArtifactReport::SCHEMA
1008
+ env["HARNEX_ARTIFACT_REPORT_MODE"] = "observed_state"
1010
1009
  env["HARNEX_ARTIFACT_REPORT_REQUIRED"] = "1" if require_artifact_report
1011
1010
  end
1012
1011
  env["HARNEX_SPAWNER_PANE"] = ENV["TMUX_PANE"] if ENV["TMUX_PANE"]
@@ -1046,7 +1045,7 @@ module Harnex
1046
1045
  injected_count = @mutex.synchronize do
1047
1046
  @injected_count += 1
1048
1047
  @last_injected_at = Time.now
1049
- persist_registry
1048
+ refresh_registry
1050
1049
  @injected_count
1051
1050
  end
1052
1051
 
@@ -1072,6 +1071,17 @@ module Harnex
1072
1071
  Harnex.write_registry(@registry_path, payload)
1073
1072
  end
1074
1073
 
1074
+ # Post-injection registry refresh. By the time this runs the prompt has
1075
+ # already reached the agent, so a failed bookkeeping write must not report
1076
+ # the send as failed — that would make an orchestrator retry a turn the
1077
+ # agent is already working on. Startup persistence stays strict: a session
1078
+ # nothing can discover should fail loudly instead of running unreachable.
1079
+ def refresh_registry
1080
+ persist_registry
1081
+ rescue StandardError => e
1082
+ warn("harnex: failed to refresh session registry: #{e.message}")
1083
+ end
1084
+
1075
1085
  def persist_exit_status
1076
1086
  return unless defined?(@exit_code) && !@exit_code.nil?
1077
1087
 
@@ -1126,19 +1136,38 @@ module Harnex
1126
1136
  rescue EOFError, Errno::EIO, IOError
1127
1137
  break
1128
1138
  end
1139
+ rescue StandardError => e
1140
+ warn("harnex: input forwarder stopped: #{e.class}: #{e.message}")
1129
1141
  end
1130
1142
  end
1131
1143
 
1144
+ # Draining the PTY is the one job this thread must never stop doing: if it
1145
+ # dies, the kernel buffer fills and the wrapped agent blocks forever on
1146
+ # write, which looks like the agent hanging rather than the harness
1147
+ # failing. Echoing to our own stdout is best-effort by comparison —
1148
+ # Errno::EPIPE/EBADF (not IOError subclasses, so they used to escape and
1149
+ # kill the thread) just mean nobody is watching, e.g. `harnex run … | head`.
1132
1150
  def start_output_thread
1133
1151
  Thread.new do
1152
+ echo = true
1134
1153
  loop do
1135
1154
  chunk = @reader.readpartial(4096)
1136
1155
  record_output(chunk)
1137
- STDOUT.write(chunk)
1138
- STDOUT.flush
1139
- rescue EOFError, Errno::EIO, IOError
1140
- break
1156
+ next unless echo
1157
+
1158
+ begin
1159
+ STDOUT.write(chunk)
1160
+ STDOUT.flush
1161
+ rescue StandardError => e
1162
+ echo = false
1163
+ warn("harnex: stopped mirroring output to stdout: #{e.message}")
1164
+ end
1141
1165
  end
1166
+ rescue EOFError, Errno::EIO, IOError
1167
+ nil
1168
+ rescue StandardError => e
1169
+ # Never disappear quietly; a dead reader must be diagnosable.
1170
+ warn("harnex: output reader stopped: #{e.class}: #{e.message}")
1142
1171
  end
1143
1172
  end
1144
1173
 
@@ -1158,7 +1187,7 @@ module Harnex
1158
1187
  def prune_retained_logs
1159
1188
  Harnex::Retention.auto_prune(
1160
1189
  repo_root: repo_root,
1161
- current_paths: [output_log_path, events_log_path]
1190
+ current_paths: [output_log_path, events_log_path, artifact_report_path, artifact_claims_path]
1162
1191
  )
1163
1192
  end
1164
1193
 
@@ -1227,19 +1256,34 @@ module Harnex
1227
1256
  end
1228
1257
 
1229
1258
  def emit_git_start_event
1230
- @git_start = Harnex.git_capture_start(repo_root)
1259
+ @git_start = Harnex.git_capture_start(
1260
+ repo_root,
1261
+ exclude_paths: git_observation_excluded_paths
1262
+ )
1231
1263
  return if @git_start.empty?
1232
1264
 
1233
1265
  emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
1234
1266
  end
1235
1267
 
1268
+ def git_observation_excluded_paths
1269
+ git_root = DispatchHistory.find_git_root(repo_root)
1270
+ return [] unless git_root
1271
+
1272
+ root = File.expand_path(git_root)
1273
+ prefix = "#{root}#{File::SEPARATOR}"
1274
+ [dispatch_history_path, artifact_report_path, artifact_claims_path].filter_map do |path|
1275
+ absolute = File.expand_path(path.to_s, root)
1276
+ absolute.delete_prefix(prefix) if absolute.start_with?(prefix)
1277
+ end.uniq
1278
+ end
1279
+
1236
1280
  def emit_session_end_telemetry
1237
1281
  summary = collect_session_summary
1238
1282
  @usage_summary = normalized_usage_summary(summary)
1239
1283
  @context_summary = normalized_context_summary(summary)
1240
1284
  emit_event("usage", **@usage_summary)
1241
1285
 
1242
- @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
1286
+ @git_end = Harnex.git_capture_end(repo_root, @git_start)
1243
1287
  return if @git_end.empty?
1244
1288
 
1245
1289
  emit_event(
@@ -1250,14 +1294,15 @@ module Harnex
1250
1294
  loc_removed: @git_end[:loc_removed],
1251
1295
  files_changed: @git_end[:files_changed],
1252
1296
  changed_paths: @git_end[:changed_paths],
1253
- commits: @git_end[:commits]
1297
+ commits: @git_end[:commits],
1298
+ start_dirty: @git_end[:start_dirty],
1299
+ end_dirty: @git_end[:end_dirty],
1300
+ worktree_changed: @git_end[:worktree_changed]
1254
1301
  )
1255
1302
  end
1256
1303
 
1257
1304
  def emit_summary_event
1258
- payload = { path: dispatch_history_path, exit: @exit_reason }
1259
- payload[:mirror_path] = summary_out if summary_out
1260
- emit_event("summary", **payload)
1305
+ emit_event("summary", path: dispatch_history_path, exit: @exit_reason)
1261
1306
  end
1262
1307
 
1263
1308
  def emit_attempt_finished(attempt)
@@ -1293,10 +1338,10 @@ module Harnex
1293
1338
  @context_summary = normalized_context_summary(nil)
1294
1339
  warn("harnex: failed to collect session-end telemetry: #{e.message}")
1295
1340
  end
1341
+ enforce_required_artifact_report!
1296
1342
  @exit_reason ||= classify_exit
1297
1343
  record = DispatchHistory.build_record(self)
1298
1344
  append_dispatch_history_record(record)
1299
- append_summary_record(record)
1300
1345
  emit_summary_event
1301
1346
  emit_attempt_finished(record.fetch(:attempt))
1302
1347
  emit_exit_event
@@ -1391,48 +1436,174 @@ module Harnex
1391
1436
  AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT
1392
1437
  end
1393
1438
 
1439
+ # Kept under the existing method name for call-site compatibility. The
1440
+ # worker no longer has to author or validate proof: Harnex overwrites the
1441
+ # configured/default path with a receipt derived from observed state.
1394
1442
  def enforce_required_artifact_report!
1395
- return unless require_artifact_report
1396
- return if task_failed? && %w[report_missing report_invalid report_rejected].include?(@completion_outcome_class)
1397
-
1398
- result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
1399
- report_fresh = result.status != "missing" && artifact_report_fresh?
1400
- report_accepted = Harnex::ArtifactReport.accepted_final?(result)
1401
- report_stale = report_accepted && !report_fresh
1402
- if report_fresh && report_accepted
1403
- @completion_outcome_class = "completed_with_proof"
1404
- @completion_report_status = "accepted"
1405
- @completion_diagnostics = []
1406
- return
1407
- end
1443
+ persist_observed_receipt!
1444
+ rescue StandardError => e
1445
+ @artifact_report_write_error = e.message
1446
+ warn("harnex: failed to write observed-state receipt #{artifact_report_path}: #{e.message}")
1447
+ return if task_failed?
1408
1448
 
1409
- report_outcome = Harnex::ArtifactReport.outcome_status(result)
1410
- outcome_class = if report_fresh && report_outcome == "rejected"
1411
- "report_rejected"
1412
- elsif result.status == "missing"
1413
- "report_missing"
1414
- else
1415
- "report_invalid"
1416
- end
1417
- report_status = if report_stale
1418
- "stale"
1419
- elsif report_fresh && report_outcome == "rejected"
1420
- "rejected"
1421
- else
1422
- result.status
1423
- end
1424
- diagnostics = report_stale ? [artifact_report_stale_diagnostic] : result.diagnostics
1449
+ @last_completed_at = nil
1425
1450
  mark_task_failed(
1426
- status: outcome_class,
1427
- error: completion_failure_message(outcome_class),
1428
- outcome_class: outcome_class,
1429
- artifact_report_status: report_status,
1430
- diagnostics: diagnostics
1451
+ status: "report_invalid",
1452
+ error: "harness could not generate the observed-state receipt",
1453
+ outcome_class: "report_invalid",
1454
+ artifact_report_status: "write_error",
1455
+ diagnostics: [{
1456
+ "code" => "receipt_write_error",
1457
+ "path" => "$",
1458
+ "message" => "harness could not write the observed-state receipt"
1459
+ }]
1431
1460
  )
1432
1461
  @exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
1433
1462
  @term_signal = nil if @exit_code == 1
1434
1463
  end
1435
1464
 
1465
+ def persist_observed_receipt!
1466
+ refresh_git_end_snapshot
1467
+ load_artifact_claims
1468
+ successful = receipt_successful?
1469
+ if successful
1470
+ @completion_outcome_class = "completed_with_proof" if task_complete? || require_artifact_report
1471
+ @completion_report_status = "accepted"
1472
+ @completion_diagnostics = []
1473
+ else
1474
+ @completion_report_status ||= "rejected"
1475
+ end
1476
+
1477
+ result = Harnex::ArtifactReport.write_observed(
1478
+ artifact_report_path,
1479
+ id: id,
1480
+ session_id: session_id,
1481
+ generated_at: Time.now.utc,
1482
+ successful: successful,
1483
+ outcome_status: receipt_outcome_status(successful),
1484
+ outcome_summary: receipt_outcome_summary(successful),
1485
+ git: receipt_git_payload,
1486
+ commands: observed_commands_snapshot,
1487
+ turn: receipt_turn_payload(successful),
1488
+ usage: build_summary_usage,
1489
+ claims: @artifact_claims,
1490
+ command_observation: command_observation_status
1491
+ )
1492
+ @artifact_report_generated = true
1493
+ @artifact_report_write_error = nil
1494
+ result
1495
+ end
1496
+
1497
+ def refresh_git_end_snapshot
1498
+ return @git_end = {} if @git_start[:sha].to_s.empty?
1499
+
1500
+ snapshot = Harnex.git_capture_end(repo_root, @git_start)
1501
+ @git_end = snapshot unless snapshot.empty?
1502
+ @git_end
1503
+ end
1504
+
1505
+ def load_artifact_claims
1506
+ return @artifact_claims if @artifact_claims_loaded
1507
+
1508
+ @artifact_claims_loaded = true
1509
+ candidates = [
1510
+ [artifact_claims_path, @artifact_claims_start_fingerprint],
1511
+ [artifact_report_path, @artifact_report_start_fingerprint]
1512
+ ]
1513
+ candidates.each do |path, start_fingerprint|
1514
+ next unless artifact_input_fresh?(path, start_fingerprint)
1515
+
1516
+ claims = Harnex::ArtifactReport.extract_claims(path)
1517
+ next if claims.empty?
1518
+
1519
+ @artifact_claims = claims
1520
+ break
1521
+ end
1522
+ @artifact_claims
1523
+ end
1524
+
1525
+ def observed_commands_snapshot
1526
+ @observed_commands_mutex.synchronize { @observed_commands.map(&:dup) }
1527
+ end
1528
+
1529
+ def command_observation_status
1530
+ adapter.transport == :stdio_jsonrpc ? "observed" : "unsupported"
1531
+ end
1532
+
1533
+ def receipt_successful?
1534
+ return false if task_failed?
1535
+ return @exit_code.to_i.zero? unless @exit_code.nil?
1536
+
1537
+ task_complete?
1538
+ end
1539
+
1540
+ def receipt_outcome_status(successful)
1541
+ return "rejected" unless successful
1542
+
1543
+ git = receipt_git_payload
1544
+ if git[:status] == "observed" &&
1545
+ git[:commits].to_i.zero? &&
1546
+ Array(git[:changed_paths]).empty? &&
1547
+ !git[:start_sha].to_s.empty? &&
1548
+ git[:start_sha].to_s == git[:end_sha].to_s
1549
+ "no_change"
1550
+ else
1551
+ "accepted"
1552
+ end
1553
+ end
1554
+
1555
+ def receipt_outcome_summary(successful)
1556
+ return "Harnex observed an unsuccessful completion." unless successful
1557
+
1558
+ if receipt_outcome_status(true) == "no_change"
1559
+ "Harnex observed successful completion with no Git delta."
1560
+ else
1561
+ "Harnex observed successful completion with repository or structured activity."
1562
+ end
1563
+ end
1564
+
1565
+ def receipt_git_payload
1566
+ start_sha = @git_start[:sha].to_s
1567
+ end_sha = @git_end[:sha].to_s
1568
+ return { status: "unavailable" } if start_sha.empty? || end_sha.empty?
1569
+
1570
+ {
1571
+ status: "observed",
1572
+ start_sha: start_sha,
1573
+ end_sha: end_sha,
1574
+ branch: @git_start[:branch],
1575
+ changed_paths: Array(@git_end[:changed_paths]),
1576
+ loc_added: @git_end[:loc_added],
1577
+ loc_removed: @git_end[:loc_removed],
1578
+ files_changed: @git_end[:files_changed],
1579
+ commits: @git_end[:commits],
1580
+ start_dirty: @git_end[:start_dirty],
1581
+ end_dirty: @git_end[:end_dirty],
1582
+ worktree_changed: @git_end[:worktree_changed]
1583
+ }
1584
+ end
1585
+
1586
+ def receipt_turn_payload(successful)
1587
+ status = if task_failed?
1588
+ "failed"
1589
+ elsif task_complete?
1590
+ "completed"
1591
+ elsif !@exit_code.nil?
1592
+ "process_exit"
1593
+ else
1594
+ "completed"
1595
+ end
1596
+ {
1597
+ status: status,
1598
+ outcome_class: @completion_outcome_class,
1599
+ task_complete: task_complete?,
1600
+ task_failed: task_failed?,
1601
+ accepted: successful,
1602
+ exit_code: @exit_code,
1603
+ signal: @term_signal
1604
+ }
1605
+ end
1606
+
1436
1607
  def normalize_work_acceptance_exit_code!
1437
1608
  return unless task_failed? && @completion_outcome_class
1438
1609
 
@@ -1482,7 +1653,7 @@ module Harnex
1482
1653
  end
1483
1654
 
1484
1655
  def build_summary_record
1485
- artifact_payload = artifact_report_path ? artifact_report_summary : nil
1656
+ artifact_payload = @artifact_report_generated ? artifact_report_summary : nil
1486
1657
  attribution = build_summary_attribution
1487
1658
  outcome = build_summary_outcome(artifact_payload)
1488
1659
  record = {
@@ -1691,16 +1862,20 @@ module Harnex
1691
1862
  end
1692
1863
 
1693
1864
  def build_summary_outcome(artifact_payload)
1694
- sidecar_outcome = artifact_payload&.dig("outcome") || {}
1865
+ receipt_outcome = artifact_payload&.dig("outcome") || {}
1695
1866
  outcome_class, report_status = summary_proof_classification
1696
- proof_failure = %w[completed_no_activity report_missing report_invalid task_failed].include?(outcome_class)
1697
- status = proof_failure ? nil : sidecar_outcome["status"]
1867
+ proof_failure = %w[completed_no_activity report_missing report_invalid report_rejected task_failed].include?(outcome_class)
1868
+ harness_receipt = artifact_payload&.dig("receipt", "author") == "harnex"
1869
+ status = receipt_outcome["status"]
1870
+ status = nil if status == "accepted" && proof_failure
1698
1871
  status = "no_change" if status.nil? && !proof_failure && @git_end.key?(:changed_paths) && @git_end[:changed_paths].empty?
1699
1872
  status ||= "unknown"
1700
1873
  status = "unknown" unless Harnex::ArtifactReport::OUTCOME_STATUSES.include?(status)
1701
- source = if proof_failure
1874
+ source = if harness_receipt
1875
+ "harnex_observed_state"
1876
+ elsif proof_failure
1702
1877
  "harnex_completion_gate"
1703
- elsif sidecar_outcome["status"]
1878
+ elsif receipt_outcome["status"]
1704
1879
  "artifact_report"
1705
1880
  else
1706
1881
  "harnex_git_observation"
@@ -1711,7 +1886,7 @@ module Harnex
1711
1886
  "class" => outcome_class,
1712
1887
  "source" => source,
1713
1888
  "report_status" => report_status,
1714
- "commit_sha" => sidecar_outcome["commit_sha"] || summary_commit_sha,
1889
+ "commit_sha" => receipt_outcome["commit_sha"] || summary_commit_sha,
1715
1890
  "changed_paths" => @git_end.key?(:changed_paths) ? @git_end[:changed_paths] : nil,
1716
1891
  "loc_added" => @git_end[:loc_added],
1717
1892
  "loc_removed" => @git_end[:loc_removed],
@@ -1725,13 +1900,10 @@ module Harnex
1725
1900
  return [@completion_outcome_class, @completion_report_status] if @completion_outcome_class
1726
1901
  return ["task_failed", @completion_report_status] if task_failed?
1727
1902
 
1728
- if artifact_report_path
1903
+ if @artifact_report_generated
1729
1904
  result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
1730
- report_fresh = result.status != "missing" && artifact_report_fresh?
1731
- report_accepted = Harnex::ArtifactReport.accepted_final?(result)
1732
- return ["completed_with_proof", "accepted"] if report_fresh && report_accepted
1733
- return ["report_rejected", "rejected"] if report_fresh && Harnex::ArtifactReport.outcome_status(result) == "rejected"
1734
- return ["unknown", "stale"] if report_accepted && !report_fresh
1905
+ return ["completed_with_proof", "accepted"] if Harnex::ArtifactReport.accepted_final?(result)
1906
+ return ["report_rejected", "rejected"] if Harnex::ArtifactReport.outcome_status(result) == "rejected"
1735
1907
  return ["unknown", result.status]
1736
1908
  end
1737
1909
 
@@ -2109,20 +2281,6 @@ module Harnex
2109
2281
  meta.is_a?(Hash) ? meta : {}
2110
2282
  end
2111
2283
 
2112
- # Explicit-only mirror: the identical end record lands here in addition
2113
- # to the tracked stream when --summary-out is configured.
2114
- def append_summary_record(record)
2115
- return unless summary_out
2116
-
2117
- FileUtils.mkdir_p(File.dirname(summary_out))
2118
- File.open(summary_out, "ab") do |file|
2119
- file.write(JSON.generate(record))
2120
- file.write("\n")
2121
- end
2122
- rescue StandardError => e
2123
- warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
2124
- end
2125
-
2126
2284
  def append_dispatch_start_record
2127
2285
  DispatchHistory.append(dispatch_history_path, DispatchHistory.build_start_record(self))
2128
2286
  rescue StandardError => e
@@ -2135,10 +2293,9 @@ module Harnex
2135
2293
  warn("harnex: failed to write dispatch history: #{e.message}")
2136
2294
  end
2137
2295
 
2138
- # One canonical stream per session: the start row and the end row must
2139
- # land in the same file so readers can pair them. repo_root — not the
2140
- # launch cwd — is the root that registry, events, and default summary
2141
- # paths already key off.
2296
+ # The only telemetry destination: the start row and the end row must land
2297
+ # in the same file so readers can pair them. repo_root — not the launch
2298
+ # cwd — is the root that registry and events paths already key off.
2142
2299
  def dispatch_history_path
2143
2300
  @dispatch_history_path ||= DispatchHistory.path_for(repo_root)
2144
2301
  end