ace-hitl 0.24.1 → 0.24.2

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: 80aaacaa6ef70bbe80b87e00b4edee2f4cf935b679a115fd5e42f001ddb0f60b
4
- data.tar.gz: 85b36915a24996df17f1a550f3c5ca017227482472183b0d27ff9b6bc09a65ec
3
+ metadata.gz: 18d85dc5d580a07b84780113776f166ded716932248787002ada4f483113b468
4
+ data.tar.gz: 1744b1e6259e9b19cf17e4f184d54be65494b6221462f46afc421c8f8e953f68
5
5
  SHA512:
6
- metadata.gz: 387437e8824ee6c94bc89c6c683434c129821527a6cf80dd3983b7bf875551d3e782c59e344294569722cc5531a8862ab6633f4616e46bb46e55c6224bb6d808
7
- data.tar.gz: 8035330d8eea2aed94243161bff6e93426befa2beda2e474d1bacd80341363a7777728851d068509b1e69a5472f0f31be9b93fcf9d13c6ae64c68538aa4602d5
6
+ metadata.gz: 6e37a7a6c0b60ac6a7b91b3059548de70d0966ff775175f5044f7e220fe1e498268bad443a8c3d5ef3c82e93cdf2dbb7865a047024e4d1cf0e1d46465dc9a21c
7
+ data.tar.gz: c48874a7f7e4177e54b358f31e1e4b3660d08165cc4f194613d8a8dbca45ccf6e47a4fc6e5f212ab9379dbcb1a9aaa426a80812576134f516014d47f979daacb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.2] - 2026-10-27
11
+
12
+ ### Added
13
+
14
+ - Add the one-turn `ask` command: create + send the question through the configured route, auto-arm the asking agent's own answer wake (ace.wake.ask/v1 request delivered through the ace-wake bridge), print the bare case id as the final stdout line, and end the turn — never block, never poll, no asker-side timeouts; undelivered routes exit 1 before any wake request is dropped, and `ask` completes on accepted-queued frames.
15
+
16
+ ### Changed
17
+
18
+ - Formalize route recipients as first-class ordered data in the route loader.
19
+
10
20
  ## [0.24.1] - 2026-10-25
11
21
 
12
22
  ### Changed
data/docs/usage.md CHANGED
@@ -159,7 +159,10 @@ 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
+ recipients: [overseer] # ordered: configured names, primary first;
163
+ # resolved to the stored destination before
164
+ # any adapter runs; appending a name extends
165
+ # coverage with zero code change
163
166
  adapter: terminal # built-in: any ace-runtime terminal
164
167
  runtime: herdr # tmux | herdr
165
168
  pane: p5 # opaque pane handle (runtime-owned)
@@ -304,6 +307,38 @@ ace-hitl wait abc123 --scope current
304
307
  The default wait is indefinite; a positive `--timeout` bounds only this call
305
308
  and never expires the request. `wait` never invents an answer.
306
309
 
310
+ ## Ask
311
+
312
+ One-turn ask: create the question, send it through the configured routes,
313
+ arm the asking agent's own wake for the case answer, and END THE TURN. ask
314
+ glues `create` + `send` + wake-arming into one command for the common case
315
+ where asking is the last action of a turn:
316
+
317
+ ```bash
318
+ ace-hitl ask "Continue with batch 2?" --to captain --kind approval --question "Proceed?"
319
+ ace-hitl ask "Auth strategy?" --to overseer --task 8xg.t.sbz.1.1
320
+ ```
321
+
322
+ ```
323
+ HITL event created: 8xhkep Continue with batch 2?
324
+ HITL event 8xhkep sent through 1 route(s):
325
+ captain (hermes_native): delivered - native transport ...
326
+ 8xhkep
327
+ ```
328
+
329
+ The final stdout line is always the bare case id. ask never blocks and never
330
+ polls, carries no urgency language, and has no asker-side timeouts — the
331
+ Captain replies whenever he wants. The answer is delivered TO the agent as
332
+ an ace-wake wake carrying the answer content (see the ace-wake README's ask
333
+ bridge contract); on wake, the agent acts immediately.
334
+
335
+ Exit 0 when the question was created, the routes accepted the frame, and
336
+ the wake was armed — including the native-style accepted-queued case (a
337
+ stderr warning notes that delivery is unconfirmed; confirmation is
338
+ asynchronous). Exit 1 only for genuine rejects (a route refusing the
339
+ frame, or a concrete configuration error); no wake request is dropped for
340
+ a rejected ask.
341
+
307
342
  ## Lifecycle event names
308
343
 
309
344
  HITL lifecycle signals use this namespace:
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+ require "ace/support/cli"
7
+
8
+ module Ace
9
+ module Hitl
10
+ module CLI
11
+ module Commands
12
+ # One-turn ask: create the question, deliver it through the configured
13
+ # adapter routes, arm the asking agent's own wake for the case answer,
14
+ # and END THE TURN. ask never blocks and never polls: the answer is
15
+ # delivered TO the agent as an ace-wake wake carrying the content, and
16
+ # the agent acts on it immediately. Asks carry no urgency language and
17
+ # no asker-side timeouts — the Captain replies whenever he wants.
18
+ class Ask < Ace::Support::Cli::Command
19
+ include Ace::Support::Cli::Base
20
+
21
+ # Shared with ace-wake's ask bridge (wake/ask-bridge.js): the drop-file
22
+ # contract that arms the asking agent's scoped answer watch.
23
+ WAKE_REQUEST_SCHEMA = "ace.wake.ask/v1"
24
+
25
+ desc "Ask: create, send, arm the answer wake, and end the turn"
26
+
27
+ example [
28
+ "ask \"Continue with batch 2?\" --to captain --kind approval --question \"Proceed?\"",
29
+ "ask \"Auth strategy?\" --to overseer --task 8xg.t.sbz.1.1"
30
+ ]
31
+
32
+ argument :title, required: false, desc: "HITL event title"
33
+
34
+ option :to, type: :string, required: true, desc: "Configured recipient name (required)"
35
+ option :kind, type: :string, aliases: %w[-k], desc: "Kind: clarification, decision, approval"
36
+ option :question, type: :string, repeat: true, aliases: %w[-Q], desc: "Question line (repeatable)"
37
+ option :task, type: :string, desc: "Task reference"
38
+ option :tags, type: :string, aliases: %w[-T], desc: "Comma-separated tags"
39
+
40
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
41
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
42
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
43
+
44
+ def call(title: nil, **options)
45
+ unless title && !title.strip.empty?
46
+ raise Ace::Support::Cli::Error.new("Title required")
47
+ end
48
+
49
+ kind = options[:kind]
50
+ validate_kind!(kind) if kind
51
+
52
+ manager = Ace::Hitl::Organisms::HitlManager.new
53
+ event = create_question(manager, title, options)
54
+ unless options[:quiet]
55
+ puts "HITL event created: #{event.id} #{event.title}"
56
+ end
57
+
58
+ deliver(event, options)
59
+ arm_wake(event, manager)
60
+ # The final stdout line is always the bare case id.
61
+ puts event.id
62
+ rescue Ace::Hitl::Molecules::HitlAddressResolver::UnknownRecipientError,
63
+ Ace::Hitl::Molecules::HitlAddressResolver::InvalidAddressError,
64
+ Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError,
65
+ Ace::Hitl::Adapters::Error,
66
+ Ace::Hitl::Molecules::HitlRouteLoader::NoRoutesError,
67
+ Ace::Hitl::Molecules::HitlRouteLoader::InvalidRouteError => e
68
+ raise Ace::Support::Cli::Error.new(e.message)
69
+ end
70
+
71
+ private
72
+
73
+ def create_question(manager, title, options)
74
+ manager.create(
75
+ title,
76
+ to: options[:to],
77
+ kind: options[:kind],
78
+ type: "question",
79
+ task: options[:task],
80
+ questions: Array(options[:question]).map(&:strip).reject(&:empty?),
81
+ tags: parse_tags(options[:tags])
82
+ )
83
+ end
84
+
85
+ def deliver(event, options)
86
+ report = Organisms::HitlSender.new.send(event.id)
87
+ rejected = report.results.select { |result| result.status == :failed }
88
+ unless rejected.empty?
89
+ # A genuine reject (ContractError, unknown adapter): the frame
90
+ # never went through, so arming a wake would strand the ask.
91
+ detail = rejected.map { |result| "#{result.route}: #{result.detail}" }.join("; ")
92
+ raise Ace::Support::Cli::Error.new("HITL event '#{event.id}' rejected: #{detail}")
93
+ end
94
+ unless options[:quiet]
95
+ puts "HITL event #{report.event_id} sent through #{report.results.size} route(s):"
96
+ report.results.each do |result|
97
+ detail = result.detail ? " - #{result.detail}" : ""
98
+ puts " #{result.route} (#{result.adapter}): #{result.status}#{detail}"
99
+ end
100
+ end
101
+ # Native-style transports accept first and confirm later: an
102
+ # ACCEPTED QUEUED frame is ask-complete. The answer wake fires
103
+ # whenever the transport confirms — delivered-later states are
104
+ # asynchronous, never an asker-side failure.
105
+ queued = report.results.select { |result| result.status == :unavailable }
106
+ unless queued.empty?
107
+ warn "ace-hitl: warning: delivery unconfirmed (queued on " \
108
+ "#{queued.map { |result| result.route }.join(", ")}); the answer wake fires when the transport delivers"
109
+ end
110
+ end
111
+
112
+ # Drop the wake request the ace-wake extension consumes through its
113
+ # own reconcile pass and turns into a scoped watch on the case file:
114
+ # when the answer lands, the wake carries the answer content. The
115
+ # atomic replace keeps the consumer from reading a torn request.
116
+ def arm_wake(event, manager)
117
+ requests_dir = File.join(File.expand_path(manager.root_dir), "wake-requests")
118
+ FileUtils.mkdir_p(requests_dir)
119
+ request = {
120
+ "schema" => WAKE_REQUEST_SCHEMA,
121
+ "kind" => "ask",
122
+ "case_id" => event.id,
123
+ "case_path" => File.expand_path(event.file_path)
124
+ }
125
+ temporary = File.join(requests_dir, ".#{event.id}-#{SecureRandom.hex(8)}.tmp")
126
+ final = File.join(requests_dir, "#{event.id}.json")
127
+ File.write(temporary, JSON.generate(request))
128
+ File.rename(temporary, final)
129
+ final
130
+ ensure
131
+ File.unlink(temporary) if temporary && File.exist?(temporary)
132
+ end
133
+
134
+ def validate_kind!(kind)
135
+ allowed = %w[clarification decision approval]
136
+ return if allowed.include?(kind)
137
+
138
+ raise Ace::Support::Cli::Error.new("Invalid kind '#{kind}'. Allowed: #{allowed.join(", ")}")
139
+ end
140
+
141
+ def parse_tags(raw)
142
+ return [] unless raw
143
+
144
+ raw.split(",").map(&:strip).reject(&:empty?)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
data/lib/ace/hitl/cli.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "ace/support/cli"
4
4
  require_relative "../hitl/version"
5
5
  require_relative "cli/commands/create"
6
+ require_relative "cli/commands/ask"
6
7
  require_relative "cli/commands/send"
7
8
  require_relative "cli/commands/operator_question"
8
9
  require_relative "cli/commands/read"
@@ -34,6 +35,7 @@ module Ace
34
35
  ["list", "List HITL events"],
35
36
  ["observe", "Read semantic event revisions"],
36
37
  ["update", "Update HITL event metadata or answer"],
38
+ ["ask", "Ask: create, send, arm the answer wake, and end the turn"],
37
39
  ["send", "Send a HITL event through the configured adapter routes"],
38
40
  ["wait", "Wait for an answer on a specific HITL event"],
39
41
  ["deliver", "Deliver an answer (stdin) to a pending HITL relay request"],
@@ -52,7 +54,9 @@ module Ace
52
54
  "ace-hitl create \"Work done\" --type report --result success --task 8xg.t.sbz.1.0",
53
55
  "ace-hitl update abc123 --answer \"Use JWT with refresh tokens\"",
54
56
  "ace-hitl send 8xhkep",
55
- "ace-hitl wait abc123 --poll-every 600 --timeout 14400"
57
+ "ace-hitl wait abc123 --poll-every 600 --timeout 14400",
58
+ "ace-hitl ask \"Continue?\" --to captain --kind approval --question \"Proceed?\"",
59
+ "ace-hitl ask \"Auth strategy?\" --to overseer --task 8xg.t.sbz.1.1"
56
60
  ].freeze
57
61
 
58
62
  register "create", CLI::Commands::Create
@@ -62,6 +66,7 @@ module Ace
62
66
  register "list", CLI::Commands::List
63
67
  register "observe", CLI::Commands::Observe
64
68
  register "update", CLI::Commands::Update
69
+ register "ask", CLI::Commands::Ask
65
70
  register "send", CLI::Commands::Send
66
71
  register "wait", CLI::Commands::Wait
67
72
  register "deliver", CLI::Commands::Deliver
@@ -20,6 +20,15 @@ module Ace
20
20
  # single path segment (no dots).
21
21
  ROUTE_NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
22
22
 
23
+ # A route's `recipients` list is first-class ORDERED data: each entry
24
+ # is a configured recipient name, position is meaningful (primary
25
+ # first), and the loader preserves the list verbatim — never
26
+ # reordered, never deduped. Selection matches an event addressed to
27
+ # ANY listed name, so extending coverage (a second-captain entry
28
+ # tomorrow) is a configuration change only: add the name under
29
+ # `hitl.recipients` and append it to the route's list. Duplicates
30
+ # carry no order meaning and are rejected as invalid.
31
+
23
32
  # @param config [Hash] merged hitl namespace config
24
33
  # @return [Array<Hash>] validated string-keyed routes
25
34
  # @raise [NoRoutesError, InvalidRouteError]
@@ -66,10 +75,19 @@ module Ace
66
75
 
67
76
  normalized["route"] = route
68
77
  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"
78
+ if normalized.key?("recipients")
79
+ list = normalized["recipients"]
80
+ unless list.is_a?(Array) && !list.empty? && list.all? { |name| name.is_a?(String) && !name.strip.empty? }
81
+ raise InvalidRouteError, "route '#{route}' recipients must be a non-empty list of configured recipient names"
82
+ end
83
+ names = list.map(&:strip)
84
+ duplicates = names.tally.select { |_, count| count > 1 }.keys
85
+ unless duplicates.empty?
86
+ raise InvalidRouteError,
87
+ "route '#{route}' recipients list repeats #{duplicates.join(", ")}: recipients are ordered data " \
88
+ "and must list each configured name once"
89
+ end
90
+ normalized["recipients"] = names
73
91
  end
74
92
  normalized
75
93
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Hitl
5
- VERSION = "0.24.1"
5
+ VERSION = "0.24.2"
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.24.1
4
+ version: 0.24.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-25 00:00:00.000000000 Z
10
+ date: 2026-10-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-hitl-contract
@@ -153,6 +153,7 @@ files:
153
153
  - lib/ace/hitl/atoms/hitl_file_pattern.rb
154
154
  - lib/ace/hitl/atoms/hitl_id_formatter.rb
155
155
  - lib/ace/hitl/cli.rb
156
+ - lib/ace/hitl/cli/commands/ask.rb
156
157
  - lib/ace/hitl/cli/commands/cancel.rb
157
158
  - lib/ace/hitl/cli/commands/consume.rb
158
159
  - lib/ace/hitl/cli/commands/create.rb