ace-hitl 0.24.1 → 0.24.3
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 +4 -4
- data/CHANGELOG.md +16 -0
- data/docs/usage.md +36 -1
- data/lib/ace/hitl/cli/commands/ask.rb +150 -0
- data/lib/ace/hitl/cli.rb +6 -1
- data/lib/ace/hitl/molecules/hitl_route_loader.rb +22 -4
- data/lib/ace/hitl/organisms/hitl_manager.rb +3 -3
- data/lib/ace/hitl/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d4e7dd5c819e98ea18006c02d1d6e587857d4889f91bd4d42fc2d0e26461d9d
|
|
4
|
+
data.tar.gz: 4a05d425d80b079f03800fa9f7277412d8a2218f4b56d11a624df50baf4325eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 292cafb6c572d818b1c0a3988a36bd588303dacfa357a585fa0166430db74e61c682ad1d45242dc44be9943110c3cf45546f9f965aee7861e75b10862b2559b2
|
|
7
|
+
data.tar.gz: 03b92ef04274f643f84f2868d19f021ff4e72f0add38d164e4e11657ee1c532571aeb6751ab29b5fe67d8475a23352e195cf8f284b347985554a02f881c8d984
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.24.3] - 2026-10-29
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Answer and field-update rebuilds parse the case file strictly: an unparsable frontmatter block aborts the mutation instead of silently rebuilding the case from an empty frontmatter (HOTFIX cycle-2b follow-up to the 8xsdfo ask-flow corruption).
|
|
15
|
+
|
|
16
|
+
## [0.24.2] - 2026-10-27
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Formalize route recipients as first-class ordered data in the route loader.
|
|
25
|
+
|
|
10
26
|
## [0.24.1] - 2026-10-25
|
|
11
27
|
|
|
12
28
|
### 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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
@@ -410,7 +410,7 @@ module Ace
|
|
|
410
410
|
|
|
411
411
|
def apply_answer_update(file_path, answer)
|
|
412
412
|
content = File.read(file_path)
|
|
413
|
-
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
413
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
414
414
|
|
|
415
415
|
updated_body = Molecules::HitlAnswerEditor.apply(body, answer)
|
|
416
416
|
answered = !answer.to_s.strip.empty?
|
|
@@ -432,7 +432,7 @@ module Ace
|
|
|
432
432
|
file.rewind
|
|
433
433
|
|
|
434
434
|
content = file.read
|
|
435
|
-
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
435
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
436
436
|
body = body.sub(/\A\n/, "")
|
|
437
437
|
previous = body.match(/^## Answer[ \t]*(?:\n(.*))?\z/m)&.[](1).to_s.strip
|
|
438
438
|
if first_answer && (frontmatter["answered"] == true || !previous.empty?)
|
|
@@ -525,7 +525,7 @@ module Ace
|
|
|
525
525
|
|
|
526
526
|
event = current[:event]
|
|
527
527
|
content = File.read(event.file_path)
|
|
528
|
-
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
528
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
529
529
|
updated_body = body.to_s.sub(/\s*\z/, "")
|
|
530
530
|
updated_body << "\n\n## Resume Dispatch\n\n#{note}\n"
|
|
531
531
|
rebuilt = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body)
|
data/lib/ace/hitl/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.24.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-29 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
|