harnex 0.7.12 → 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.
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
@@ -0,0 +1,94 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Harnex
5
+ class ArtifactReportCommand
6
+ def self.usage(program_name = "harnex artifact-report")
7
+ <<~TEXT
8
+ Usage:
9
+ #{program_name} init PATH [--force]
10
+ #{program_name} validate PATH [--final]
11
+
12
+ Commands:
13
+ init Write a bounded harnex.artifact_report.v1 skeleton
14
+ validate Validate schema and field shapes without printing report data
15
+
16
+ Options:
17
+ --final Require an accepted/no_change final outcome, passing proof,
18
+ and validation.final_reported=true
19
+ --force Replace an existing file during init
20
+ -h, --help Show this help
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.
25
+ TEXT
26
+ end
27
+
28
+ def initialize(argv)
29
+ @argv = argv.dup
30
+ end
31
+
32
+ def run
33
+ command = @argv.shift
34
+ case command
35
+ when "init"
36
+ run_init(@argv)
37
+ when "validate"
38
+ run_validate(@argv)
39
+ when nil, "help", "-h", "--help"
40
+ puts self.class.usage
41
+ 0
42
+ else
43
+ raise OptionParser::ParseError, "unknown artifact-report command #{command.inspect}"
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def run_init(argv)
50
+ options = { force: false, help: false }
51
+ parser = OptionParser.new do |opts|
52
+ opts.banner = "Usage: harnex artifact-report init PATH [--force]"
53
+ opts.on("--force", "Replace an existing report") { options[:force] = true }
54
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
55
+ end
56
+ parser.parse!(argv)
57
+ return print_help if options[:help]
58
+
59
+ path = exactly_one_path!(argv, parser)
60
+ report_path = ArtifactReport.initialize_file(path, force: options[:force])
61
+ result = ArtifactReport.validate(report_path)
62
+ puts JSON.generate(result.public_payload(final: false).merge("created" => true))
63
+ result.ok ? 0 : 1
64
+ end
65
+
66
+ def run_validate(argv)
67
+ options = { final: false, help: false }
68
+ parser = OptionParser.new do |opts|
69
+ opts.banner = "Usage: harnex artifact-report validate PATH [--final]"
70
+ opts.on("--final", "Require accepted final proof") { options[:final] = true }
71
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
72
+ end
73
+ parser.parse!(argv)
74
+ return print_help if options[:help]
75
+
76
+ path = exactly_one_path!(argv, parser)
77
+ result = ArtifactReport.validate(path, final: options[:final])
78
+ puts JSON.generate(result.public_payload(final: options[:final]))
79
+ result.ok ? 0 : 1
80
+ end
81
+
82
+ def exactly_one_path!(argv, parser)
83
+ raise OptionParser::MissingArgument, "PATH" if argv.empty?
84
+ raise OptionParser::InvalidArgument, "expected exactly one PATH\n#{parser}" unless argv.length == 1
85
+
86
+ argv.first
87
+ end
88
+
89
+ def print_help
90
+ puts self.class.usage
91
+ 0
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,170 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Harnex
5
+ class OrchestrationCommand
6
+ def self.usage(program_name = "harnex orchestration")
7
+ <<~TEXT
8
+ Usage:
9
+ #{program_name} sample --out PATH --run-id ID --generation-id ID [options]
10
+ #{program_name} report --dispatch PATH --run-id ID [--samples PATH] [--json]
11
+
12
+ Sample options:
13
+ --project-id ID
14
+ --queue-id ID
15
+ --session-id ID
16
+ --event NAME sample, generation_started, generation_finished, rotation, recovery, or compaction
17
+ --ts ISO8601
18
+ --context-status STATUS observed, estimated, unsupported, missing, or zero
19
+ --context-tokens N
20
+ --context-window-tokens N
21
+ --context-percent N
22
+ --context-peak-tokens N
23
+ --context-peak-percent N
24
+ --usage-status STATUS observed, estimated, unsupported, missing, or zero
25
+ --usage-input-tokens N
26
+ --usage-output-tokens N
27
+ --usage-cached-input-tokens N
28
+ --usage-reasoning-tokens N
29
+ --usage-total-tokens N
30
+ --usage-cost-usd N
31
+ --usage-cost-source TEXT
32
+ --tool-calls N
33
+ --compactions N
34
+ --rotation-reason TEXT
35
+
36
+ Report options:
37
+ --dispatch PATH Dispatch summary JSONL
38
+ --samples PATH External primary sample JSONL
39
+ --run-id ID Logical orchestration run id
40
+ --json Output the full report JSON
41
+ -h, --help Show this help
42
+ TEXT
43
+ end
44
+
45
+ def initialize(argv)
46
+ @argv = argv.dup
47
+ end
48
+
49
+ def run
50
+ subcommand = @argv.shift
51
+ case subcommand
52
+ when "sample"
53
+ run_sample(@argv)
54
+ when "report"
55
+ run_report(@argv)
56
+ when "-h", "--help", nil
57
+ puts self.class.usage
58
+ 0
59
+ else
60
+ raise OptionParser::ParseError, "unknown orchestration subcommand #{subcommand.inspect}"
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def run_sample(argv)
67
+ options = {
68
+ context: {},
69
+ usage: {}
70
+ }
71
+ sample_parser(options).parse!(argv)
72
+ if options.delete(:help)
73
+ puts self.class.usage
74
+ return 0
75
+ end
76
+ path = required_option(options.delete(:out), "--out")
77
+ options["orchestration_run_id"] = required_option(options.delete(:run_id), "--run-id")
78
+ options["generation_id"] = required_option(options.delete(:generation_id), "--generation-id")
79
+ sample = Harnex::Orchestration.append_sample(path, options)
80
+ puts JSON.generate("ok" => true, "path" => path, "sample" => sample)
81
+ 0
82
+ end
83
+
84
+ def run_report(argv)
85
+ options = { json: false }
86
+ report_parser(options).parse!(argv)
87
+ if options[:help]
88
+ puts self.class.usage
89
+ return 0
90
+ end
91
+ report = Harnex::Orchestration.report(
92
+ dispatch_path: required_option(options[:dispatch], "--dispatch"),
93
+ samples_path: options[:samples],
94
+ run_id: required_option(options[:run_id], "--run-id")
95
+ )
96
+ if options[:json]
97
+ puts JSON.generate(report)
98
+ else
99
+ puts render_report(report)
100
+ end
101
+ 0
102
+ end
103
+
104
+ def sample_parser(options)
105
+ OptionParser.new do |opts|
106
+ opts.banner = "Usage: harnex orchestration sample --out PATH --run-id ID --generation-id ID [options]"
107
+ opts.on("--out PATH") { |value| options[:out] = value }
108
+ opts.on("--run-id ID") { |value| options[:run_id] = value }
109
+ opts.on("--generation-id ID") { |value| options[:generation_id] = value }
110
+ opts.on("--project-id ID") { |value| options["project_id"] = value }
111
+ opts.on("--queue-id ID") { |value| options["queue_id"] = value }
112
+ opts.on("--session-id ID") { |value| options["session_id"] = value }
113
+ opts.on("--event NAME") { |value| options["event"] = value }
114
+ opts.on("--ts ISO8601") { |value| options["ts"] = value }
115
+ opts.on("--context-status STATUS") { |value| options[:context]["status"] = value }
116
+ opts.on("--context-tokens N") { |value| options[:context]["terminal_tokens"] = value }
117
+ opts.on("--context-window-tokens N") { |value| options[:context]["window_tokens"] = value }
118
+ opts.on("--context-percent N") { |value| options[:context]["terminal_percent"] = value }
119
+ opts.on("--context-peak-tokens N") { |value| options[:context]["peak_tokens"] = value }
120
+ opts.on("--context-peak-percent N") { |value| options[:context]["peak_percent"] = value }
121
+ opts.on("--usage-status STATUS") { |value| options[:usage]["status"] = value }
122
+ opts.on("--usage-input-tokens N") { |value| options[:usage]["input_tokens"] = value }
123
+ opts.on("--usage-output-tokens N") { |value| options[:usage]["output_tokens"] = value }
124
+ opts.on("--usage-cached-input-tokens N") { |value| options[:usage]["cached_input_tokens"] = value }
125
+ opts.on("--usage-reasoning-tokens N") { |value| options[:usage]["reasoning_tokens"] = value }
126
+ opts.on("--usage-total-tokens N") { |value| options[:usage]["total_tokens"] = value }
127
+ opts.on("--usage-cost-usd N") { |value| options[:usage]["cost_usd"] = value }
128
+ opts.on("--usage-cost-source TEXT") { |value| options[:usage]["cost_source"] = value }
129
+ opts.on("--tool-calls N") { |value| options["tool_calls"] = value }
130
+ opts.on("--compactions N") { |value| options["compactions"] = value }
131
+ opts.on("--rotation-reason TEXT") { |value| options["rotation_reason"] = value }
132
+ opts.on("-h", "--help") { options[:help] = true }
133
+ end
134
+ end
135
+
136
+ def report_parser(options)
137
+ OptionParser.new do |opts|
138
+ opts.banner = "Usage: harnex orchestration report --dispatch PATH --run-id ID [options]"
139
+ opts.on("--dispatch PATH") { |value| options[:dispatch] = value }
140
+ opts.on("--samples PATH") { |value| options[:samples] = value }
141
+ opts.on("--run-id ID") { |value| options[:run_id] = value }
142
+ opts.on("--json") { options[:json] = true }
143
+ opts.on("-h", "--help") { options[:help] = true }
144
+ end
145
+ end
146
+
147
+ def required_option(value, name)
148
+ text = value.to_s.strip
149
+ raise OptionParser::MissingArgument, name if text.empty?
150
+
151
+ text
152
+ end
153
+
154
+ def render_report(report)
155
+ primary = report.fetch("primary")
156
+ workers = report.fetch("workers")
157
+ ratios = report.fetch("ratios")
158
+ [
159
+ "Run: #{report.fetch('orchestration_run_id')}",
160
+ "Primary: generations=#{primary.fetch('generation_count')} usage=#{primary.dig('usage', 'status')} total_tokens=#{format_value(primary.dig('usage', 'total_tokens'))} peak_context=#{format_value(primary.dig('context', 'peak_tokens'))} tool_calls=#{primary.fetch('tool_calls')}",
161
+ "Workers: dispatches=#{workers.fetch('dispatches')} usage=#{workers.dig('usage', 'status')} active_s=#{workers.fetch('active_s')} accepted=#{workers.dig('outcomes', 'accepted')} rejected=#{workers.dig('outcomes', 'rejected')} blocked=#{workers.dig('outcomes', 'blocked')} unknown=#{workers.dig('outcomes', 'unknown')}",
162
+ "Ratios: primary_tokens_per_accepted_entry=#{format_value(ratios.fetch('primary_total_tokens_per_accepted_entry'))} primary_tool_calls_per_accepted_entry=#{format_value(ratios.fetch('primary_tool_calls_per_accepted_entry'))}"
163
+ ].join("\n")
164
+ end
165
+
166
+ def format_value(value)
167
+ value.nil? ? "-" : value
168
+ end
169
+ end
170
+ end
@@ -16,7 +16,15 @@ module Harnex
16
16
  "--plan" => "plan",
17
17
  "--intent" => "intent",
18
18
  "--model" => "model",
19
- "--effort" => "effort"
19
+ "--effort" => "effort",
20
+ "--parent-dispatch-id" => "parent_dispatch_id",
21
+ "--parent-attempt-id" => "parent_attempt_id",
22
+ "--attempt-kind" => "attempt_kind",
23
+ "--orchestration-run-id" => "orchestration_run_id",
24
+ "--orchestration-generation-id" => "orchestration_generation_id",
25
+ "--orchestration-role" => "orchestration_role",
26
+ "--orchestration-session-id" => "orchestration_session_id",
27
+ "--orchestration-rotation-reason" => "orchestration_rotation_reason"
20
28
  }.freeze
21
29
  TELEMETRY_KEYS_TO_FLAGS = TELEMETRY_FLAGS.invert.freeze
22
30
  TELEMETRY_EQUALS_PREFIXES = TELEMETRY_FLAGS.keys.map { |flag| "#{flag}=" }.freeze
@@ -25,7 +33,7 @@ module Harnex
25
33
  --id --description --detach --tmux --host --port --watch --watch-file
26
34
  --stall-after --max-resumes --preset --context --meta --summary-out
27
35
  --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
28
- --require-attribution --auto-stop --fast --legacy-pty --help
36
+ --require-artifact-report --require-attribution --auto-stop --fast --legacy-pty --help
29
37
  ].concat(TELEMETRY_FLAGS.keys).freeze
30
38
  VALUE_FLAGS = %w[
31
39
  --id --description --host --port --watch --watch-file --stall-after
@@ -50,7 +58,7 @@ module Harnex
50
58
  --preset NAME Watch preset: impl, plan, gate (requires --watch)
51
59
  --watch-file PATH Auto-send a file-change hook on modification
52
60
  --context TEXT Inject as the initial prompt (prepends session header)
53
- --auto-stop Stop after the first task completion from --context
61
+ --auto-stop Stop after the first accepted task completion from --context
54
62
  --fast (codex only) Use Codex service_tier="fast".
55
63
  Default Codex runs force service_tier="flex".
56
64
  --meta JSON Attach parsed JSON metadata to the started event
@@ -59,6 +67,9 @@ module Harnex
59
67
  Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
60
68
  --validation-report PATH
61
69
  Alias for --artifact-report; also exposed as HARNEX_VALIDATION_REPORT_PATH
70
+ --require-artifact-report
71
+ Fail closed unless PATH contains accepted final proof;
72
+ requires --artifact-report or --validation-report
62
73
  --project-id ID Queue telemetry project id (first-class flags override --meta)
63
74
  --queue-id ID Queue telemetry queue id
64
75
  --entry-id ID Queue telemetry entry id
@@ -70,6 +81,22 @@ module Harnex
70
81
  --intent TEXT Queue/work intent telemetry
71
82
  --model NAME Requested model metadata (also used for structured dispatch)
72
83
  --effort LEVEL Requested reasoning effort metadata (structured dispatch)
84
+ --parent-dispatch-id ID
85
+ Parent dispatch id for retry/fix/review joins
86
+ --parent-attempt-id ID
87
+ Parent attempt id for retry/fix/review joins
88
+ --attempt-kind KIND
89
+ initial, retry, fix, review, or superseding (default: initial)
90
+ --orchestration-run-id ID
91
+ Logical primary-orchestrator run id for queue rollups
92
+ --orchestration-generation-id ID
93
+ Primary generation id after rotation/recovery boundaries
94
+ --orchestration-role ROLE
95
+ primary or worker; defaults to worker in rollups when omitted
96
+ --orchestration-session-id ID
97
+ External primary session id to preserve in rollups
98
+ --orchestration-rotation-reason TEXT
99
+ Clean rotation/recovery reason for the generation
73
100
  --require-attribution
74
101
  Fail before launch unless project/phase/intent and one work id are present
75
102
  --cwd DIR Run the wrapped agent from DIR and use DIR as the session root
@@ -85,7 +112,9 @@ module Harnex
85
112
  Notes:
86
113
  Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
87
114
  Bare `--watch` enables the babysitter.
88
- --auto-stop requires --context and fires once after the first completion.
115
+ --auto-stop requires --context. Structured Codex turns only count as
116
+ accepted completion after activity, Git delta, or accepted sidecar proof.
117
+ --require-artifact-report makes sidecar validation part of the run verdict.
89
118
  Explicit --stall-after/--max-resumes values override --preset defaults.
90
119
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
91
120
  Any other CLI name is launched with generic wrapping.
@@ -131,6 +160,7 @@ module Harnex
131
160
  require_attribution: false,
132
161
  summary_out: nil,
133
162
  artifact_report: nil,
163
+ require_artifact_report: false,
134
164
  cwd: nil,
135
165
  root: nil,
136
166
  auto_stop: false,
@@ -154,7 +184,10 @@ module Harnex
154
184
 
155
185
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
156
186
  validate_auto_stop_context!
187
+ validate_required_artifact_report!
157
188
  apply_telemetry_options!
189
+ validate_attempt_metadata!
190
+ validate_orchestration_metadata!
158
191
  validate_required_attribution!
159
192
 
160
193
  repo_root = resolve_run_root(cli_name, child_args)
@@ -227,6 +260,7 @@ module Harnex
227
260
  tmux_cmd << "--require-attribution" if @options[:require_attribution]
228
261
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
229
262
  tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
263
+ tmux_cmd << "--require-artifact-report" if @options[:require_artifact_report]
230
264
  tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
231
265
  tmux_cmd += ["--root", @options[:root]] if @options[:root]
232
266
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
@@ -337,6 +371,7 @@ module Harnex
337
371
  meta: @options[:meta],
338
372
  summary_out: @options[:summary_out],
339
373
  artifact_report_path: @options[:artifact_report],
374
+ require_artifact_report: @options[:require_artifact_report],
340
375
  inbox_ttl: @options[:inbox_ttl],
341
376
  auto_stop: @options[:auto_stop],
342
377
  launch_cwd: history_cwd,
@@ -529,6 +564,8 @@ module Harnex
529
564
  when "--artifact-report", "--validation-report"
530
565
  index += 1
531
566
  @options[:artifact_report] = required_option_value(arg, argv[index])
567
+ when "--require-artifact-report"
568
+ @options[:require_artifact_report] = true
532
569
  when /\A--artifact-report=(.+)\z/
533
570
  @options[:artifact_report] = required_option_value("--artifact-report", Regexp.last_match(1))
534
571
  when /\A--validation-report=(.+)\z/
@@ -609,7 +646,7 @@ module Harnex
609
646
  case arg
610
647
  when "--"
611
648
  return false
612
- when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-attribution", "--fast", "--legacy-pty"
649
+ when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-artifact-report", "--require-attribution", "--fast", "--legacy-pty"
613
650
  nil
614
651
  when /\A--tmux=/
615
652
  nil
@@ -666,6 +703,14 @@ module Harnex
666
703
  raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
667
704
  end
668
705
 
706
+ def validate_required_artifact_report!
707
+ return unless @options[:require_artifact_report]
708
+ return unless @options[:artifact_report].to_s.strip.empty?
709
+
710
+ raise OptionParser::InvalidOption,
711
+ "harnex run: --require-artifact-report requires --artifact-report PATH"
712
+ end
713
+
669
714
  def apply_telemetry_options!
670
715
  explicit = @options[:telemetry]
671
716
  return if explicit.empty? && @options[:meta].is_a?(Hash)
@@ -674,6 +719,24 @@ module Harnex
674
719
  @options[:meta] = (@options[:meta].is_a?(Hash) ? @options[:meta].dup : {}).merge(explicit)
675
720
  end
676
721
 
722
+ def validate_attempt_metadata!
723
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
724
+ kind = metadata["attempt_kind"].to_s
725
+ return if kind.empty? || Session::ATTEMPT_KINDS.include?(kind)
726
+
727
+ raise OptionParser::InvalidOption,
728
+ "harnex run: --attempt-kind must be one of #{Session::ATTEMPT_KINDS.join(', ')}"
729
+ end
730
+
731
+ def validate_orchestration_metadata!
732
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
733
+ role = metadata["orchestration_role"].to_s
734
+ return if role.empty? || Orchestration::ROLES.include?(role)
735
+
736
+ raise OptionParser::InvalidOption,
737
+ "harnex run: --orchestration-role must be one of #{Orchestration::ROLES.join(', ')}"
738
+ end
739
+
677
740
  def validate_required_attribution!
678
741
  return unless @options[:require_attribution]
679
742
 
@@ -215,8 +215,11 @@ module Harnex
215
215
  process_state: "running",
216
216
  terminal: false,
217
217
  task_complete: !failed,
218
+ task_failed: failed,
218
219
  done: !failed,
219
- work_state: failed ? "failed" : "completed"
220
+ work_state: failed ? "failed" : "completed",
221
+ outcome_class: event["outcome_class"],
222
+ artifact_report_status: event["artifact_report_status"]
220
223
  )
221
224
  payload[:last_error] = event["message"] || event["error"] if failed
222
225
  end
@@ -520,6 +523,8 @@ module Harnex
520
523
  task_failed: task_failed,
521
524
  done: done,
522
525
  work_state: work_state,
526
+ outcome_class: status["outcome_class"],
527
+ artifact_report_status: status["artifact_report_status"],
523
528
  exit: status["exit"],
524
529
  exit_code: status["exit_code"],
525
530
  summary_out: status["summary_out"],
@@ -304,6 +304,8 @@ module Harnex
304
304
  exit_code: exit_code,
305
305
  status: payload["status"],
306
306
  work_state: payload["work_state"],
307
+ outcome_class: payload["outcome_class"],
308
+ artifact_report_status: payload["artifact_report_status"],
307
309
  task_complete: payload["task_complete"] || payload["event"] == "task_complete",
308
310
  task_failed: payload["task_failed"] || payload["event"] == "task_failed",
309
311
  done: payload["done"],
@@ -0,0 +1,104 @@
1
+ module Harnex
2
+ # Bounded in-memory aggregate for active context-window samples.
3
+ #
4
+ # It retains only the final valid occupancy sample, independent high-water
5
+ # marks, and counters that make unavailable samples visible without storing
6
+ # raw protocol payloads.
7
+ class ContextTelemetry
8
+ MEASUREMENT_STATUSES = %w[observed estimated].freeze
9
+
10
+ def initialize(status:, source:)
11
+ measurement_status = status.to_s
12
+ unless MEASUREMENT_STATUSES.include?(measurement_status)
13
+ raise ArgumentError, "context telemetry status must be observed or estimated"
14
+ end
15
+
16
+ @measurement_status = measurement_status
17
+ @source = source.to_s.empty? ? nil : source.to_s
18
+ @terminal_tokens = nil
19
+ @window_tokens = nil
20
+ @terminal_percent = nil
21
+ @peak_tokens = nil
22
+ @peak_percent = nil
23
+ @samples = 0
24
+ @missing_samples = 0
25
+ @latest_sample_status = nil
26
+ @valid_sample_seen = false
27
+ end
28
+
29
+ def record(tokens:, window_tokens:, percent: nil)
30
+ @samples += 1
31
+ token_count = nonnegative_integer(tokens)
32
+ window_size = positive_integer(window_tokens)
33
+ pressure_percent = nonnegative_float(percent)
34
+ pressure_percent ||= derived_percent(token_count, window_size)
35
+
36
+ if token_count.nil?
37
+ @missing_samples += 1
38
+ @latest_sample_status = "missing"
39
+ return snapshot
40
+ end
41
+
42
+ @valid_sample_seen = true
43
+ @terminal_tokens = token_count
44
+ @window_tokens = window_size
45
+ @terminal_percent = pressure_percent
46
+ @peak_tokens = [@peak_tokens, token_count].compact.max
47
+ @peak_percent = [@peak_percent, pressure_percent].compact.max
48
+ @latest_sample_status = @measurement_status
49
+ snapshot
50
+ end
51
+
52
+ def snapshot
53
+ {
54
+ status: @valid_sample_seen ? @measurement_status : "missing",
55
+ source: @source,
56
+ terminal_tokens: @terminal_tokens,
57
+ window_tokens: @window_tokens,
58
+ terminal_percent: @terminal_percent,
59
+ peak_tokens: @peak_tokens,
60
+ peak_percent: @peak_percent,
61
+ samples: @samples,
62
+ missing_samples: @missing_samples,
63
+ latest_sample_status: @latest_sample_status
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ def nonnegative_integer(value)
70
+ return nil if value.nil?
71
+
72
+ parsed = Integer(value)
73
+ return nil if value.is_a?(Numeric) && value.to_f != parsed.to_f
74
+ return nil if parsed.negative?
75
+
76
+ parsed
77
+ rescue ArgumentError, TypeError, FloatDomainError
78
+ nil
79
+ end
80
+
81
+ def positive_integer(value)
82
+ parsed = nonnegative_integer(value)
83
+ parsed&.positive? ? parsed : nil
84
+ end
85
+
86
+ def nonnegative_float(value)
87
+ return nil if value.nil?
88
+
89
+ parsed = Float(value)
90
+ return nil unless parsed.finite?
91
+ return nil if parsed.negative?
92
+
93
+ parsed
94
+ rescue ArgumentError, TypeError
95
+ nil
96
+ end
97
+
98
+ def derived_percent(token_count, window_size)
99
+ return nil if token_count.nil? || window_size.nil?
100
+
101
+ ((token_count.to_f / window_size) * 100).round(2)
102
+ end
103
+ end
104
+ end
data/lib/harnex/core.rb CHANGED
@@ -141,6 +141,7 @@ module Harnex
141
141
  end_sha = git_output(repo_root, "rev-parse", "HEAD")
142
142
  range = "#{start_sha}..#{end_sha}"
143
143
  shortstat = git_output(repo_root, "diff", "--shortstat", range)
144
+ changed_paths = git_output(repo_root, "diff", "--name-only", range).lines.map(&:strip).reject(&:empty?).first(200)
144
145
  commits = Integer(git_output(repo_root, "rev-list", "--count", range))
145
146
  stats = parse_git_shortstat(shortstat)
146
147
 
@@ -149,6 +150,7 @@ module Harnex
149
150
  loc_added: stats.fetch(:loc_added),
150
151
  loc_removed: stats.fetch(:loc_removed),
151
152
  files_changed: stats.fetch(:files_changed),
153
+ changed_paths: changed_paths,
152
154
  commits: commits
153
155
  }
154
156
  rescue StandardError