ace-hitl 0.23.1 → 0.24.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9186e91db61d509bafd0c44d8ad023ed208614ad8cbfe749d3b49b0bbf10772
4
- data.tar.gz: 151a5ad5cda6dc92256a7f2f604249858c04666c23a5a6ce85c9347dbf4640f4
3
+ metadata.gz: 066770bf07c22e97f6d1571d8d7697a9681df2872102b9454e26dc934d494162
4
+ data.tar.gz: 398b6537945687a92f34a0284d9e687f7b41cfea777352c9b7046a07ef45cd9a
5
5
  SHA512:
6
- metadata.gz: 8f6a6ef440d4404df3868ddc1e416032a09a1b59eb36e9d9a9317f214939677a5c58a724ec8cebd10bf88a7d5d1f412671f724be0957dd54c938b54cf06a1c44
7
- data.tar.gz: 6dd4dae6d026e1167dcb7225188f7b5e73fd9fbfc4fde1625e1988daf4fb4922b2e8a477e5f142c68aad6032a806512fc6506ab0ad05eb71aed144627965e137
6
+ metadata.gz: cb37ecff31019de3e8007964a8631e9f8e33c7dd5ba4be5505b84a4172c030e7eadf4580e8e70b962b8d26942a4b1a6221ed9a7641adb8726e4dce408b11c734
7
+ data.tar.gz: a7ecb1759adaa8e6ae5dae068eb556bc0f094cd905a11b12f30f1f1bd6012ea61f68487eeeddf48298ee92de212b7b5c57c893e371a2234b20b9ca18f1475035
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.0] - 2026-10-23
11
+
12
+ ### Added
13
+
14
+ - Add read-only semantic event observations with configured-owner filtering; exclude delivery metadata and unrelated recipients.
15
+
16
+ ## [0.23.2] - 2026-10-20
17
+
18
+ ### Fixed
19
+
20
+ - Select addressed-event routes by configured recipient names resolved to the
21
+ exact stored address. Unrelated terminal/Telegram routes no longer send or
22
+ create false delivery failures. Zero matches fail before adapter IO.
23
+ **Configuration migration:** add `recipients: [name]` to each addressed route;
24
+ names must exist in `hitl.recipients`. Recipient-less local events retain their
25
+ explicitly configured route set.
26
+ - Accept native Herdr panel handles such as `w7:p1` without changing machine or
27
+ folder validation.
28
+ - Provide first-answer-wins `HitlManager#accept_answer` for native transport
29
+ imports; exact replays preserve bytes and conflicting answers cannot replace
30
+ an existing answer under the case write lock.
31
+
10
32
  ## [0.23.1] - 2026-10-18
11
33
 
12
34
  ### Fixed
data/README.md CHANGED
@@ -78,3 +78,15 @@ bin/ace-test ace-hitl all
78
78
  `ace-hitl` owns HITL event semantics and the markdown contract.
79
79
  `ace-support-items` remains generic support infrastructure and does not absorb
80
80
  HITL-specific behaviour.
81
+
82
+ ### Read semantic event revisions
83
+
84
+ `ace-hitl observe --scope current --limit 500` prints one JSON object with schema
85
+ `ace.hitl.observation/v1` and a bounded `events` list. `--scope all` includes linked
86
+ worktrees of this repository. Each row contains a hashed canonical file identity,
87
+ event ID, semantic revision hash, type/status and task/assignment references.
88
+ It emits no private message or answer text. It reads active (non-special-folder) canonical events and
89
+ does not import transports, acknowledge, archive, resume or mutate events.
90
+ Delivery/poll timestamps and filesystem mtime are excluded from revisions;
91
+ content, answers and report/status changes are included. Exceeding the configured
92
+ limit refuses the entire projection rather than silently truncating it.
data/docs/usage.md CHANGED
@@ -88,7 +88,7 @@ ace-hitl create "Blocked on auth" --kind decision --to overseer --task 8xg.t.sbz
88
88
  original case and the right agent — task/assignment/step are context,
89
89
  never an ID substitute.
90
90
  - Route delivery (`send`) requires a panel-form recipient. A folder-form
91
- recipient has no shipped delivery route: every route refuses it per-route
91
+ recipient has no shipped delivery adapter: selected adapters refuse it per-route
92
92
  with a clear `failed` reason (the event file already sitting in a folder
93
93
  is a local write, not a delivery confirmation). Refusal semantics are
94
94
  strict on both axes of the address: the terminal adapter delivers only
@@ -159,11 +159,13 @@ hitl:
159
159
  adapters:
160
160
  routes:
161
161
  - route: overseer-terminal # display name; defaults to the adapter name
162
+ recipients: [overseer] # configured names resolved to the stored destination
162
163
  adapter: terminal # built-in: any ace-runtime terminal
163
164
  runtime: herdr # tmux | herdr
164
165
  pane: p5 # opaque pane handle (runtime-owned)
165
166
  machine: lab01 # defaults to hitl.machine; recipient machine must match
166
167
  - route: captain-telegram
168
+ recipients: [captain]
167
169
  adapter: hermes # ships in ace-hitl-hermes
168
170
  channel: project
169
171
  machine: lab01 # defaults to hitl.machine
@@ -176,14 +178,20 @@ ace-hitl send 8xhkep
176
178
  ```
177
179
 
178
180
  ```
179
- HITL event 8xhkep sent through 2 route(s):
181
+ HITL event 8xhkep sent through 1 route(s):
180
182
  overseer-terminal (terminal): delivered - text pushed to herdr pane p5
181
- captain-telegram (hermes): unavailable - ...
182
183
  ```
183
184
 
184
185
  Rules:
185
186
 
186
- - One result line per route; a route's success never hides another route's
187
+ - For an addressed event, only routes whose `recipients` resolve to its exact
188
+ stored machine and panel/folder are selected. Changing a configured alias does
189
+ not redirect an existing case. Missing selectors do not match addressed events;
190
+ no match fails before adapter invocation or delivery-state writes. Recipient-less
191
+ events use the explicitly configured route set.
192
+ - Several matching routes deliver to the same intended recipient independently.
193
+ Unmatched routes are not invoked or recorded as failures. One result line per
194
+ selected route; a route's success never hides another selected route's
187
195
  failure. Exit 0 only when every route is `delivered`; exit 1 on any
188
196
  `failed`/`unavailable` route, no configured routes (concrete error naming
189
197
  `hitl.adapters.routes`), or unknown/ambiguous ref. An unknown adapter on
@@ -199,6 +207,8 @@ Rules:
199
207
  on questions only, task/assignment/step context.
200
208
  - `delivered` means accepted by the route's transport. An unavailable
201
209
  adapter never reroutes to another recipient.
210
+ - Native terminal handles such as `w7:p1` are accepted unchanged as `panel`.
211
+ This does not relax the machine-token or folder-traversal rules.
202
212
  - Adding an adapter requires no ace-hitl change: a gem ships
203
213
  `lib/ace/hitl/adapters/<name>.rb` calling `Ace::Hitl::Adapters.register`
204
214
  (duck contract: `factory.call(route)` → adapter with
@@ -11,6 +11,7 @@ module Ace
11
11
  # Display strings align with the hermes <machine>/<name>/<id> shape.
12
12
  class HitlAddress
13
13
  TOKEN_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
14
+ PANEL_PATTERN = /\A[a-z0-9][a-z0-9_:-]{0,127}\z/i.freeze
14
15
 
15
16
  class InvalidAddress < StandardError; end
16
17
 
@@ -48,8 +49,8 @@ module Ace
48
49
  address["id"] = id.to_s
49
50
  end
50
51
  else
51
- unless panel.to_s.match?(TOKEN_PATTERN)
52
- raise InvalidAddress, "address panel '#{panel}' is not a valid token"
52
+ unless panel.to_s.match?(PANEL_PATTERN)
53
+ raise InvalidAddress, "address panel '#{panel}' is not a valid terminal handle"
53
54
  end
54
55
 
55
56
  address["panel"] = panel.to_s
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require "ace/support/cli"
3
+ require_relative "../../molecules/event_observation"
4
+ module Ace
5
+ module Hitl
6
+ module CLI
7
+ module Commands
8
+ class Observe < Ace::Support::Cli::Command
9
+ desc "Read bounded semantic event revisions without private message text"
10
+ option :scope, type: :string, default: "current", desc: "current or all linked worktrees"
11
+ option :owner, type: :string, desc: "Configured recipient owning incoming events"
12
+ option :limit, type: :integer, default: 500, desc: "Maximum event count (1..2000)"
13
+ def call(**options)
14
+ scope = options[:scope] || "current"
15
+ raise Ace::Support::Cli::Error, "invalid scope" unless %w[current all].include?(scope)
16
+ puts JSON.generate(Molecules::EventObservation.new.call(scope: scope, limit: options[:limit] || 500, owner: options[:owner]))
17
+ rescue ArgumentError => e
18
+ raise Ace::Support::Cli::Error, e.message
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/ace/hitl/cli.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "cli/commands/operator_question"
8
8
  require_relative "cli/commands/read"
9
9
  require_relative "cli/commands/show"
10
10
  require_relative "cli/commands/list"
11
+ require_relative "cli/commands/observe"
11
12
  require_relative "cli/commands/update"
12
13
  require_relative "cli/commands/wait"
13
14
  require_relative "cli/commands/deliver"
@@ -31,6 +32,7 @@ module Ace
31
32
  ["read", "Read authenticated lifecycle request facts"],
32
33
  ["show", "Show HITL event details"],
33
34
  ["list", "List HITL events"],
35
+ ["observe", "Read semantic event revisions"],
34
36
  ["update", "Update HITL event metadata or answer"],
35
37
  ["send", "Send a HITL event through the configured adapter routes"],
36
38
  ["wait", "Wait for an answer on a specific HITL event"],
@@ -58,6 +60,7 @@ module Ace
58
60
  register "read", CLI::Commands::Read
59
61
  register "show", CLI::Commands::Show
60
62
  register "list", CLI::Commands::List
63
+ register "observe", CLI::Commands::Observe
61
64
  register "update", CLI::Commands::Update
62
65
  register "send", CLI::Commands::Send
63
66
  register "wait", CLI::Commands::Wait
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Molecules
8
+ # Read-only semantic projection. No private text is emitted; delivery
9
+ # timestamps, filesystem metadata and observer acknowledgements are not
10
+ # decision changes. Canonical HITL remains the only event owner.
11
+ class EventObservation
12
+ def initialize(manager: Organisms::HitlManager.new)
13
+ @manager = manager
14
+ end
15
+
16
+ def call(scope: "current", limit: 500, owner: nil)
17
+ raise ArgumentError, "invalid observation limit" unless (1..2000).cover?(limit)
18
+ events = @manager.list(in_folder: "next", scope: scope, limit: limit, strict: true)
19
+ raise ArgumentError, "observation exceeds configured event budget" if events.size > limit
20
+ address = owner && Molecules::HitlAddressResolver.new(config: @manager.config).resolve(owner)
21
+ events = events.select { |event| incoming?(event, address) } if address
22
+ rows = events.map do |event|
23
+ semantic = [event.id, event.type, event.status, event.result, event.kind,
24
+ event.title, event.content, event.answer, event.questions,
25
+ Array(event.tags).sort, canonical(event.recipient), canonical(event.reply_to),
26
+ event.metadata.values_at("task", "assignment", "step", "step_name", "resume_instructions")]
27
+ {"key" => Digest::SHA256.hexdigest(event.file_path.to_s),
28
+ "id" => event.id, "revision" => Digest::SHA256.hexdigest(JSON.generate(canonical(semantic))),
29
+ "type" => event.type, "status" => event.status, "result" => event.result,
30
+ "task" => event.metadata["task"], "assignment" => event.metadata["assignment"]}
31
+ end
32
+ {"schema" => "ace.hitl.observation/v1", "events" => rows.sort_by { |row| row["key"] }}
33
+ end
34
+
35
+ private
36
+
37
+ # Questions created locally are outbound until actually answered. A
38
+ # worker's reply belongs to that worker, not every linked-worktree actor.
39
+ # Native instructions have no recipient; their explicit import root owns them.
40
+ def incoming?(event, address)
41
+ local = File.expand_path(event.file_path).start_with?(File.expand_path(@manager.root_dir) + File::SEPARATOR)
42
+ return local if Array(event.tags).include?("hermes-instruction")
43
+ if event.question? && event.answered?
44
+ return local && event.reply_to &&
45
+ event.reply_to["machine"] == @manager.config.dig("hitl", "machine") &&
46
+ File.expand_path(event.reply_to["folder"].to_s) == File.expand_path(@manager.root_dir)
47
+ end
48
+ recipient = event.recipient
49
+ recipient && address.all? { |key, value| recipient[key] == value }
50
+ end
51
+
52
+ def canonical(value)
53
+ case value
54
+ when Hash then value.sort.to_h.transform_values { |item| canonical(item) }
55
+ when Array then value.map { |item| canonical(item) }
56
+ else value
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "hitl_address_resolver"
4
+
3
5
  module Ace
4
6
  module Hitl
5
7
  module Molecules
@@ -64,9 +66,37 @@ module Ace
64
66
 
65
67
  normalized["route"] = route
66
68
  normalized["adapter"] = adapter
69
+ if normalized.key?("recipients") &&
70
+ (!normalized["recipients"].is_a?(Array) || normalized["recipients"].empty? ||
71
+ !normalized["recipients"].all? { |name| name.is_a?(String) && !name.strip.empty? })
72
+ raise InvalidRouteError, "route '#{route}' recipients must be a non-empty list of configured recipient names"
73
+ end
67
74
  normalized
68
75
  end
69
76
 
77
+ # Selection happens before adapter construction or delivery-state writes.
78
+ # Names are configuration references, not labels copied onto an event:
79
+ # compare their resolved addresses to the event's immutable destination.
80
+ def self.for_recipient(routes, recipient:, config:)
81
+ return routes if recipient.nil?
82
+
83
+ resolver = HitlAddressResolver.new(config: config)
84
+ destination = recipient.reject { |key, _| key == "id" }
85
+ selected = routes.select do |route|
86
+ addresses = Array(route["recipients"]).map do |name|
87
+ resolver.resolve(name).reject { |key, _| key == "id" }
88
+ end
89
+ addresses.include?(destination)
90
+ end
91
+ if selected.empty?
92
+ raise NoRoutesError, "no adapter route matches recipient #{Atoms::HitlAddress.to_s(recipient)}; " \
93
+ "set route recipients to configured recipient names in #{CONFIG_LOCATION}"
94
+ end
95
+ selected
96
+ rescue HitlAddressResolver::UnknownRecipientError, HitlAddressResolver::InvalidAddressError => e
97
+ raise InvalidRouteError, e.message
98
+ end
99
+
70
100
  def self.stringified(entry)
71
101
  entry.each_with_object({}) do |(key, value), memo|
72
102
  memo[key.to_s] = value
@@ -27,6 +27,7 @@ module Ace
27
27
  end
28
28
 
29
29
  class NotAnswerableError < StandardError; end
30
+ class AnswerConflictError < StandardError; end
30
31
 
31
32
  attr_reader :root_dir, :last_list_total, :last_folder_counts, :config
32
33
 
@@ -93,10 +94,12 @@ module Ace
93
94
  }
94
95
  end
95
96
 
96
- def list(status: nil, kind: nil, type: nil, delivery: nil, in_folder: "next", tags: [], scope: nil)
97
+ def list(status: nil, kind: nil, type: nil, delivery: nil, in_folder: "next", tags: [], scope: nil, limit: nil, strict: false)
97
98
  effective_scope = @scope_resolver.effective_scope(scope)
98
99
  scan_results = scan_results_for_scope(effective_scope, in_folder: in_folder)
100
+ raise ArgumentError, "observation exceeds configured event budget" if limit && scan_results.size > limit
99
101
  events = load_events(scan_results)
102
+ raise ArgumentError, "incomplete HITL observation" if strict && events.size != scan_results.size
100
103
 
101
104
  events = events.select { |event| event.status == status } if status
102
105
  events = events.select { |event| event.kind == kind } if kind
@@ -153,6 +156,21 @@ module Ace
153
156
  loader.load(current_path, id: event.id, special_folder: current_special)
154
157
  end
155
158
 
159
+ # Transport imports are first-answer-wins. The case's own write lock
160
+ # checks the answer again, so replay cannot overwrite a human answer.
161
+ # Explicit `update --answer` remains an intentional editing operation.
162
+ def accept_answer(ref, answer:, scope: nil)
163
+ resolved = resolve_for_mutation(ref, scope: scope)
164
+ return nil unless resolved
165
+ event = resolved[:event]
166
+ raise NotAnswerableError, "reports do not await an answer" if event.report?
167
+ raise NotAnswerableError, "managed requests require their authenticated lifecycle" if event.metadata["lab_request_id"]
168
+ raise ArgumentError, "answer is empty" if answer.to_s.strip.empty?
169
+ apply_field_and_answer_updates(event.file_path, set: {}, add: {}, remove: {},
170
+ answer: answer, first_answer: true)
171
+ Molecules::HitlLoader.new.load(event.path, id: event.id, special_folder: event.special_folder)
172
+ end
173
+
156
174
  def wait_for_answer(ref, scope: nil, poll_every: 600, timeout: 14_400, waiter: {}, now_proc: nil, sleeper: nil)
157
175
  poll_every = normalize_poll_seconds(poll_every)
158
176
  timeout = normalize_timeout_seconds(timeout)
@@ -408,7 +426,7 @@ module Ace
408
426
  File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
409
427
  end
410
428
 
411
- def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:)
429
+ def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:, first_answer: false)
412
430
  File.open(file_path, File::RDWR) do |file|
413
431
  file.flock(File::LOCK_EX)
414
432
  file.rewind
@@ -416,6 +434,11 @@ module Ace
416
434
  content = file.read
417
435
  frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
418
436
  body = body.sub(/\A\n/, "")
437
+ previous = body.match(/^## Answer[ \t]*(?:\n(.*))?\z/m)&.[](1).to_s.strip
438
+ if first_answer && (frontmatter["answered"] == true || !previous.empty?)
439
+ raise AnswerConflictError, "case already has a different answer" unless previous == answer.to_s.strip
440
+ return
441
+ end
419
442
 
420
443
  Ace::Support::Items::Molecules::FieldUpdater.apply_set(frontmatter, set)
421
444
  Ace::Support::Items::Molecules::FieldUpdater.apply_add(frontmatter, add)
@@ -57,6 +57,7 @@ module Ace
57
57
  raise Adapters::Error, "HITL event '#{ref}' not found" unless current
58
58
 
59
59
  event = current[:event]
60
+ routes = Molecules::HitlRouteLoader.for_recipient(routes, recipient: event.recipient, config: @config)
60
61
  text = Atoms::HitlEventText.call(event)
61
62
  delivery = event.metadata["delivery"]
62
63
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Hitl
5
- VERSION = "0.23.1"
5
+ VERSION = "0.24.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-hitl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.1
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-20 00:00:00.000000000 Z
10
+ date: 2026-10-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-hitl-contract
@@ -160,6 +160,7 @@ files:
160
160
  - lib/ace/hitl/cli/commands/duty.rb
161
161
  - lib/ace/hitl/cli/commands/lifecycle_command.rb
162
162
  - lib/ace/hitl/cli/commands/list.rb
163
+ - lib/ace/hitl/cli/commands/observe.rb
163
164
  - lib/ace/hitl/cli/commands/operator_question.rb
164
165
  - lib/ace/hitl/cli/commands/pending.rb
165
166
  - lib/ace/hitl/cli/commands/read.rb
@@ -198,6 +199,7 @@ files:
198
199
  - lib/ace/hitl/lifecycle/tls_service.rb
199
200
  - lib/ace/hitl/live_client.rb
200
201
  - lib/ace/hitl/models/hitl_event.rb
202
+ - lib/ace/hitl/molecules/event_observation.rb
201
203
  - lib/ace/hitl/molecules/hitl_address_resolver.rb
202
204
  - lib/ace/hitl/molecules/hitl_answer_editor.rb
203
205
  - lib/ace/hitl/molecules/hitl_config_loader.rb