ace-hitl 0.17.0 → 0.23.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -0
  3. data/README.md +60 -117
  4. data/docs/usage.md +247 -421
  5. data/handbook/workflow-instructions/hitl.wf.md +55 -6
  6. data/lib/ace/hitl/adapters/registry.rb +65 -0
  7. data/lib/ace/hitl/adapters/terminal.rb +75 -0
  8. data/lib/ace/hitl/adapters.rb +80 -0
  9. data/lib/ace/hitl/atoms/hitl_address.rb +89 -0
  10. data/lib/ace/hitl/atoms/hitl_event_text.rb +54 -0
  11. data/lib/ace/hitl/cli/commands/create.rb +81 -12
  12. data/lib/ace/hitl/cli/commands/lifecycle_command.rb +1 -7
  13. data/lib/ace/hitl/cli/commands/list.rb +22 -1
  14. data/lib/ace/hitl/cli/commands/send.rb +85 -0
  15. data/lib/ace/hitl/cli/commands/serve.rb +25 -44
  16. data/lib/ace/hitl/cli/commands/show.rb +23 -3
  17. data/lib/ace/hitl/cli/commands/update.rb +4 -0
  18. data/lib/ace/hitl/cli/commands/wait.rb +5 -0
  19. data/lib/ace/hitl/cli.rb +8 -18
  20. data/lib/ace/hitl/lifecycle/frame_serving.rb +0 -15
  21. data/lib/ace/hitl/lifecycle/kinds.rb +1 -1
  22. data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +0 -36
  23. data/lib/ace/hitl/lifecycle/policy.rb +0 -44
  24. data/lib/ace/hitl/lifecycle/protocol.rb +1 -2
  25. data/lib/ace/hitl/lifecycle/service.rb +2 -3
  26. data/lib/ace/hitl/lifecycle/store.rb +11 -75
  27. data/lib/ace/hitl/lifecycle/tls_access_policy.rb +0 -14
  28. data/lib/ace/hitl/lifecycle/tls_project_scope.rb +0 -15
  29. data/lib/ace/hitl/lifecycle/tls_service.rb +2 -3
  30. data/lib/ace/hitl/lifecycle.rb +2 -4
  31. data/lib/ace/hitl/live_client.rb +11 -87
  32. data/lib/ace/hitl/models/hitl_event.rb +35 -0
  33. data/lib/ace/hitl/molecules/hitl_address_resolver.rb +80 -0
  34. data/lib/ace/hitl/molecules/hitl_config_loader.rb +22 -1
  35. data/lib/ace/hitl/molecules/hitl_creator.rb +126 -27
  36. data/lib/ace/hitl/molecules/hitl_delivery_state.rb +58 -0
  37. data/lib/ace/hitl/molecules/hitl_display_formatter.rb +3 -2
  38. data/lib/ace/hitl/molecules/hitl_loader.rb +19 -2
  39. data/lib/ace/hitl/molecules/hitl_route_loader.rb +108 -0
  40. data/lib/ace/hitl/organisms/hitl_manager.rb +73 -6
  41. data/lib/ace/hitl/organisms/hitl_sender.rb +148 -0
  42. data/lib/ace/hitl/providers/lab.rb +15 -129
  43. data/lib/ace/hitl/version.rb +1 -1
  44. data/lib/ace/hitl.rb +8 -1
  45. metadata +12 -43
  46. data/lib/ace/hitl/cli/commands/ask.rb +0 -136
  47. data/lib/ace/hitl/cli/commands/overseer_ack.rb +0 -30
  48. data/lib/ace/hitl/cli/commands/overseer_pending.rb +0 -28
  49. data/lib/ace/hitl/cli/commands/overseer_send.rb +0 -35
  50. data/lib/ace/hitl/cli/commands/proposal.rb +0 -75
  51. data/lib/ace/hitl/lifecycle/overseer.rb +0 -111
  52. data/lib/ace/hitl/lifecycle/proposals.rb +0 -297
  53. data/lib/ace/hitl/lifecycle/service_publication_binding.rb +0 -97
  54. data/lib/ace/hitl/proposals/evaluator.rb +0 -29
  55. data/lib/ace/hitl/proposals/policy.rb +0 -127
  56. data/lib/ace/hitl/providers/lab/assignment_binding.rb +0 -126
  57. data/lib/ace/hitl/providers/lab/protected_assignment_binding.rb +0 -75
  58. data/lib/ace/hitl/providers/providers.rb +0 -33
@@ -3,8 +3,8 @@ doc-type: workflow
3
3
  title: HITL Workflow
4
4
  purpose: Standardize human-attention handling for blocked and completed work using ace-hitl events.
5
5
  ace-docs:
6
- last-updated: '2026-04-02'
7
- last-checked: '2026-04-02'
6
+ last-updated: '2026-10-18'
7
+ last-checked: '2026-10-18'
8
8
  ---
9
9
 
10
10
  # HITL Workflow
@@ -15,12 +15,27 @@ Use `ace-hitl` as the canonical contract whenever agent flow needs explicit huma
15
15
 
16
16
  ## When To Use HITL
17
17
 
18
- Create a HITL event in exactly two cases:
18
+ Create a HITL event in exactly two shapes:
19
19
 
20
- 1. **Blocker requiring human judgment** (ambiguity, product decision, policy/approval gate).
21
- 2. **Work is complete but explicit user attention is required** before follow-up actions.
20
+ 1. **Question requiring a decision** (blocker needing human judgment: ambiguity,
21
+ product decision, policy/approval gate, or completed work that needs explicit
22
+ confirmation). Questions stay pending and can hold the assignment until the
23
+ Reply arrives; no answer is never consent or success.
24
+ 2. **Report** (work done / error notification that needs no reply):
25
+ `--type report --result success|error` with optional `--body` lines and
26
+ task/assignment/step references. Reports are recorded, never pending, and
27
+ `wait`/`--answer`/`--resume` on them are explicit usage errors.
22
28
 
23
- Do not create HITL events for routine status updates that do not require user action.
29
+ Do not create question events for routine status updates that do not require
30
+ user action; use a report instead.
31
+
32
+ ## Planned surface (L2)
33
+
34
+ The normal local path uses plain event files: `create`, `list`, `show`, `update`,
35
+ `wait` below -- for both questions and reports. A `serve` boundary service,
36
+ grants, OTP challenges and proposals are not required for this path. Reply
37
+ routing to the correct remote case and configurable adapters (single or in a
38
+ set) land in the remaining L2 leaves; local write is not delivery.
24
39
 
25
40
  ## Commands
26
41
 
@@ -54,6 +69,19 @@ ace-hitl create "Review completed assignment results" \
54
69
  --resume "/as-assign-drive <assignment-id>"
55
70
  ```
56
71
 
72
+ ### 2b) Create a work-done or error report (no reply expected)
73
+
74
+ ```bash
75
+ ace-hitl create "Work done" \
76
+ --type report --result success \
77
+ --body "Implemented the change; tests green." \
78
+ --task <task-ref> --assignment <assignment-id> --step <step-number>
79
+ ```
80
+
81
+ Reports land with status `recorded`; never wait on them and never answer them.
82
+ The overseer reads them and archives processed reports with
83
+ `ace-hitl update <hitl-id> --move-to archive`.
84
+
57
85
  ### 3) Discover HITL work
58
86
 
59
87
  ```bash
@@ -76,6 +104,27 @@ Polling is the default reliability path for the requesting agent:
76
104
  ace-hitl wait <hitl-id>
77
105
  ```
78
106
 
107
+ ### 4b) Remote delivery through configured adapter routes
108
+
109
+ When `hitl.adapters.routes` is configured, deliver the persisted event
110
+ before waiting. Every route reports its own result; a route failure never
111
+ silently reroutes to another recipient.
112
+
113
+ ```bash
114
+ ace-hitl send <hitl-id>
115
+ ```
116
+
117
+ Per-route outcomes are retained on the event file: a later `send` retries
118
+ only undelivered or unknown-result routes (a crashed send leaves an unknown
119
+ result that the next `send` retries — at-least-once, recipients dedup by
120
+ the case ID); `--force` redelivers every route; `--dry-run` previews the
121
+ decisions. Discover retained events with `ace-hitl list --delivery
122
+ undelivered` and inspect retained failure reasons with `ace-hitl show`.
123
+ Redelivery retries the delivery of the same case — never the assignment.
124
+
125
+ An unavailable or failed route is an escalation reason (retry or fix the
126
+ route configuration); the local event file remains the durable record.
127
+
79
128
  If the waiter is no longer active, operator fallback can dispatch resume:
80
129
 
81
130
  ```bash
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Adapters
6
+ # Duck-typed adapter registry with lazy entrypoint loading.
7
+ # Semantics mirror Ace::Runtime::Registry (kept independent on
8
+ # purpose): fail-closed resolution carrying the available list;
9
+ # only a missing entrypoint counts as unknown — a LoadError raised
10
+ # inside an installed entrypoint propagates so a broken adapter is
11
+ # never misreported as unknown.
12
+ class Registry
13
+ NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
14
+ ADAPTER_ENTRYPOINT_FORMAT = "ace/hitl/adapters/%s"
15
+
16
+ def initialize
17
+ @factories = {}
18
+ end
19
+
20
+ def register(name, factory = nil, &block)
21
+ key = name.to_s
22
+ callable = factory || block
23
+ unless callable.respond_to?(:call)
24
+ raise ArgumentError, "adapter factory for '#{key}' must respond to #call"
25
+ end
26
+
27
+ @factories[key] = callable
28
+ nil
29
+ end
30
+
31
+ def registered?(name)
32
+ @factories.key?(name.to_s)
33
+ end
34
+
35
+ def available
36
+ @factories.keys.sort
37
+ end
38
+
39
+ # @return [Object] the built adapter (factory receives the route
40
+ # configuration hash)
41
+ # @raise [UnknownAdapterError] when the name cannot be resolved
42
+ def resolve(name, route: {})
43
+ key = name.to_s
44
+ load_adapter_entrypoint(key) unless @factories.key?(key)
45
+
46
+ factory = @factories[key]
47
+ raise UnknownAdapterError.new(requested: key, available: available) unless factory
48
+
49
+ factory.call(route)
50
+ end
51
+
52
+ private
53
+
54
+ def load_adapter_entrypoint(name)
55
+ return unless name.match?(NAME_PATTERN)
56
+
57
+ entrypoint = format(ADAPTER_ENTRYPOINT_FORMAT, name)
58
+ require entrypoint
59
+ rescue LoadError => e
60
+ raise unless e.path == entrypoint
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime"
4
+ require_relative "../adapters"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module Adapters
9
+ # Built-in terminal adapter: pushes the shared event text to a pane
10
+ # through any Ace::Runtime terminal (tmux, herdr). The runtime is
11
+ # named by the route configuration, so switching transports is pure
12
+ # configuration — no sender or coworker change.
13
+ #
14
+ # The event's recipient steers the target: a panel-form address on
15
+ # the route's machine names the pane the text goes to (the route
16
+ # `pane` is only the fallback for legacy recipient-less events).
17
+ # Every other recipient — folder form, or a panel on a different
18
+ # machine — is an explicit per-route refusal, never a silent send
19
+ # to the route's own pane.
20
+ class TerminalAdapter
21
+ def initialize(route)
22
+ @route_name = route["route"].to_s.strip
23
+ @runtime_name = route["runtime"].to_s.strip
24
+ @pane = route["pane"].to_s.strip
25
+ @machine = route["machine"].to_s
26
+ if @runtime_name.empty?
27
+ raise Error, "terminal route '#{@route_name}' missing required 'runtime' (tmux|herdr)"
28
+ end
29
+ if @pane.empty?
30
+ raise Error, "terminal route '#{@route_name}' missing required 'pane' handle"
31
+ end
32
+ end
33
+
34
+ def deliver(event:, text:, recipient:, reply_to:)
35
+ pane = target_pane(recipient)
36
+ unless pane
37
+ return RouteResult.new(route: @route_name, adapter: "terminal", status: :failed,
38
+ detail: "terminal route '#{@route_name}' on machine '#{@machine}' cannot serve " \
39
+ "recipient #{Atoms::HitlAddress.to_s(recipient)} (panel-form recipient " \
40
+ "on this machine required; route pane '#{@pane}' not used)")
41
+ end
42
+
43
+ runtime = Ace::Runtime.resolve(@runtime_name)
44
+ runtime.send_text(pane: pane, text: text)
45
+ RouteResult.new(route: @route_name, adapter: "terminal", status: :delivered,
46
+ detail: "text pushed to #{@runtime_name} pane #{pane}")
47
+ rescue Ace::Runtime::UnknownRuntimeError,
48
+ Ace::Runtime::RuntimeUnavailableError => e
49
+ RouteResult.new(route: @route_name, adapter: "terminal", status: :unavailable,
50
+ detail: e.message)
51
+ rescue Ace::Runtime::Error => e
52
+ RouteResult.new(route: @route_name, adapter: "terminal", status: :failed,
53
+ detail: e.message)
54
+ end
55
+
56
+ private
57
+
58
+ # The recipient's panel names the target pane, but only when the
59
+ # recipient lives on this route's machine (route `machine`,
60
+ # defaulting to hitl.machine) — a same-named pane on another
61
+ # machine is not the recipient. Without a recipient (legacy
62
+ # local-only events) the route's configured pane is used.
63
+ def target_pane(recipient)
64
+ return @pane if recipient.nil?
65
+ return nil unless recipient["machine"] == @machine
66
+
67
+ panel = recipient["panel"].to_s
68
+ panel.empty? ? nil : panel
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ Ace::Hitl::Adapters.register(:terminal, ->(route) { Ace::Hitl::Adapters::TerminalAdapter.new(route) })
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapters/registry"
4
+
5
+ module Ace
6
+ module Hitl
7
+ # Duck-typed HITL delivery-adapter registry (mirrors Ace::Runtime).
8
+ #
9
+ # Adapter contract (transport translation only — never role rules):
10
+ #
11
+ # Ace::Hitl::Adapters.register(:name, ->(route) { adapter })
12
+ # adapter.deliver(event:, text:, recipient:, reply_to:)
13
+ # -> Adapters::RouteResult(route:, adapter:, status:, detail:)
14
+ # status: :delivered | :failed | :unavailable
15
+ #
16
+ # `text` is the shared rendering from Atoms::HitlEventText — identical
17
+ # for every adapter. `recipient`/`reply_to` are the address hashes from
18
+ # the event (nil for legacy local-only events). A route delivers only
19
+ # to its own configured destination and never reroutes on any status.
20
+ #
21
+ # Adapter packages ship a lib/ace/hitl/adapters/<name>.rb entrypoint
22
+ # that calls Adapters.register; it is loaded lazily on first resolve,
23
+ # so a future adapter (e.g. nervus) needs no ace-hitl change.
24
+ module Adapters
25
+ class Error < StandardError; end
26
+
27
+ # Unknown adapter name; carries the requested name and the sorted
28
+ # list of currently available adapters.
29
+ class UnknownAdapterError < Error
30
+ attr_reader :requested, :available
31
+
32
+ def initialize(requested:, available:)
33
+ @requested = requested
34
+ @available = available
35
+ super("unknown HITL adapter '#{requested}' (available: #{available.sort.join(", ")})")
36
+ end
37
+ end
38
+
39
+ # Per-route delivery outcome. `detail` is a human-readable,
40
+ # single-line explanation (may be nil for a plain success).
41
+ RouteResult = Struct.new(:route, :adapter, :status, :detail, keyword_init: true) do
42
+ def delivered?
43
+ status == :delivered
44
+ end
45
+ end
46
+
47
+ class << self
48
+ def register(name, factory = nil, &block)
49
+ registry.register(name, factory, &block)
50
+ end
51
+
52
+ def registry
53
+ @registry ||= Registry.new
54
+ end
55
+
56
+ # Resetting re-registers the built-in adapters: a plain Registry.new
57
+ # would permanently lose :terminal (the entrypoint require is a
58
+ # no-op once loaded), breaking registry-level resolution in tests
59
+ # and long-lived processes.
60
+ def reset_registry!
61
+ @registry = Registry.new
62
+ register(:terminal, ->(route) { TerminalAdapter.new(route) })
63
+ nil
64
+ end
65
+
66
+ def available
67
+ registry.available
68
+ end
69
+
70
+ def registered?(name)
71
+ registry.registered?(name)
72
+ end
73
+
74
+ def resolve(name, route: {})
75
+ registry.resolve(name, route: route)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Atoms
6
+ # Pure recipient/reply address validation and formatting. No IO.
7
+ #
8
+ # Two forms (program contract 8xg.t.sbz.1.1):
9
+ # - folder: machine + events folder + case id (id stamped at create)
10
+ # - panel: machine + panel
11
+ # Display strings align with the hermes <machine>/<name>/<id> shape.
12
+ class HitlAddress
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
15
+
16
+ class InvalidAddress < StandardError; end
17
+
18
+ # @param hash [Hash] symbol or string keys: machine, folder/panel, id
19
+ # @return [Hash] validated string-keyed address
20
+ # @raise [InvalidAddress] with an actionable message
21
+ def self.validate!(hash)
22
+ raise InvalidAddress, "address must be a mapping" unless hash.is_a?(Hash)
23
+
24
+ machine = hash[:machine] || hash["machine"]
25
+ folder = hash[:folder] || hash["folder"]
26
+ panel = hash[:panel] || hash["panel"]
27
+ id = hash[:id] || hash["id"]
28
+
29
+ unless machine.to_s.match?(TOKEN_PATTERN)
30
+ raise InvalidAddress, "address machine '#{machine}' is not a valid token"
31
+ end
32
+
33
+ if folder.to_s.strip.empty? && panel.to_s.strip.empty?
34
+ raise InvalidAddress, "address needs exactly one of folder or panel"
35
+ end
36
+ unless folder.to_s.strip.empty? || panel.to_s.strip.empty?
37
+ raise InvalidAddress, "address cannot have both folder and panel"
38
+ end
39
+
40
+ address = {"machine" => machine.to_s}
41
+ unless folder.to_s.strip.empty?
42
+ folder = folder.to_s
43
+ if folder.split(File::SEPARATOR).include?("..")
44
+ raise InvalidAddress, "address folder '#{folder}' must not traverse outside its root"
45
+ end
46
+
47
+ address["folder"] = folder
48
+ if id && !id.to_s.empty?
49
+ address["id"] = id.to_s
50
+ end
51
+ else
52
+ unless panel.to_s.match?(PANEL_PATTERN)
53
+ raise InvalidAddress, "address panel '#{panel}' is not a valid terminal handle"
54
+ end
55
+
56
+ address["panel"] = panel.to_s
57
+ end
58
+
59
+ address
60
+ end
61
+
62
+ # @return [Boolean] true when the address carries an events folder
63
+ def self.folder_form?(address)
64
+ address.is_a?(Hash) && !address["folder"].to_s.empty?
65
+ end
66
+
67
+ # @return [Boolean] true when the address carries a panel
68
+ def self.panel_form?(address)
69
+ address.is_a?(Hash) && !address["panel"].to_s.empty?
70
+ end
71
+
72
+ # @return [String] canonical display form
73
+ def self.to_s(address)
74
+ return "" unless address.is_a?(Hash)
75
+
76
+ if folder_form?(address)
77
+ parts = [address["machine"], address["folder"]]
78
+ parts << address["id"] unless address["id"].to_s.empty?
79
+ parts.join("/")
80
+ elsif panel_form?(address)
81
+ "#{address["machine"]}/panel/#{address["panel"]}"
82
+ else
83
+ address["machine"].to_s
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hitl_address"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Atoms
8
+ # Shared, adapter-independent rendering of a HITL event for delivery.
9
+ # Every route receives the identical text — the adapter translates
10
+ # transport, not role rules. Pure; no IO.
11
+ class HitlEventText
12
+ # @param event [Models::HitlEvent]
13
+ # @return [String] single rendered message body
14
+ def self.call(event)
15
+ lines = []
16
+ if event.report?
17
+ lines << "[HITL report #{event.result}] #{event.title} (case #{event.id})"
18
+ body_lines(event).each { |line| lines << line }
19
+ else
20
+ lines << "[HITL question] #{event.title} (case #{event.id})"
21
+ questions = event.questions.empty? ? ["(pending human input)"] : event.questions
22
+ questions.each { |q| lines << "Q: #{q}" }
23
+ if event.reply_to
24
+ lines << "Reply-To: #{HitlAddress.to_s(event.reply_to)}"
25
+ end
26
+ end
27
+
28
+ context_lines(event).each { |line| lines << line }
29
+ lines.join("\n")
30
+ end
31
+
32
+ # Report body = the `## Report` section the creator writes. The
33
+ # section boundary is the next `## ` heading; reports are created by
34
+ # HitlCreator with plain `- ` content lines only, so nothing is lost
35
+ # for creator-written reports (documented boundary).
36
+ def self.body_lines(event)
37
+ body = event.content.to_s
38
+ report_section = body.split(/^## Report\s*$/m, 2)[1].to_s
39
+ report_section = report_section.sub(/\A\n+/, "").split(/^## /m, 2)[0].to_s
40
+ lines = report_section.lines.map(&:strip).reject(&:empty?)
41
+ lines.empty? ? ["(no content)"] : lines
42
+ end
43
+
44
+ def self.context_lines(event)
45
+ lines = []
46
+ lines << "Task: #{event.metadata["task"]}" if event.metadata["task"].to_s.strip != ""
47
+ lines << "Assignment: #{event.metadata["assignment"]}" if event.metadata["assignment"].to_s.strip != ""
48
+ lines << "Step: #{event.metadata["step"]}" if event.metadata["step"].to_s.strip != ""
49
+ lines
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -13,7 +13,12 @@ module Ace
13
13
 
14
14
  argument :title, required: false, desc: "HITL event title"
15
15
 
16
- option :kind, type: :string, aliases: %w[-k], desc: "Kind: clarification, decision, approval"
16
+ option :kind, type: :string, aliases: %w[-k], desc: "Kind: clarification, decision, approval (questions only)"
17
+ option :type, type: :string, aliases: %w[-t], desc: "Type: question (default), report"
18
+ option :result, type: :string, desc: "Report result: success, error (reports only)"
19
+ option :to, type: :string, desc: "Configured recipient name (machine+folder+ID or machine+panel)"
20
+ option :body, type: :string, repeat: true, aliases: %w[-b], desc: "Report content line (repeatable, reports only)"
21
+ option :task, type: :string, desc: "Task reference"
17
22
  option :question, type: :string, repeat: true, aliases: %w[-Q], desc: "Question line (repeatable)"
18
23
  option :tags, type: :string, aliases: %w[-T], desc: "Comma-separated tags"
19
24
  option :assignment, type: :string, desc: "Assignment reference"
@@ -34,25 +39,50 @@ module Ace
34
39
  kind = options[:kind]
35
40
  validate_kind!(kind) if kind
36
41
 
42
+ type = options[:type]
43
+ validate_type!(type) if type
44
+
37
45
  questions = Array(options[:question]).map(&:strip).reject(&:empty?)
46
+ body = Array(options[:body]).map(&:strip).reject(&:empty?)
47
+ result = options[:result]
48
+ validate_combinations!(type, result: result, body: body, questions: questions,
49
+ resume: options[:resume], kind: kind)
50
+ validate_result!(result) if result
51
+
38
52
  tags = parse_tags(options[:tags])
39
53
 
40
54
  manager = Ace::Hitl::Organisms::HitlManager.new
41
- event = manager.create(
42
- title,
43
- kind: kind,
44
- questions: questions,
45
- tags: tags,
46
- assignment: options[:assignment],
47
- step: options[:step],
48
- step_name: options[:"step-name"],
49
- resume_instructions: options[:resume],
50
- move_to: options[:"move-to"]
51
- )
55
+ event = begin
56
+ manager.create(
57
+ title,
58
+ to: options[:to],
59
+ kind: kind,
60
+ type: type,
61
+ result: result,
62
+ task: options[:task],
63
+ body: body,
64
+ questions: questions,
65
+ tags: tags,
66
+ assignment: options[:assignment],
67
+ step: options[:step],
68
+ step_name: options[:"step-name"],
69
+ resume_instructions: options[:resume],
70
+ move_to: options[:"move-to"]
71
+ )
72
+ rescue Ace::Hitl::Molecules::HitlAddressResolver::UnknownRecipientError,
73
+ Ace::Hitl::Molecules::HitlAddressResolver::InvalidAddressError => e
74
+ raise Ace::Support::Cli::Error.new(e.message)
75
+ end
52
76
 
53
77
  folder_info = event.special_folder ? " (#{event.special_folder})" : ""
54
78
  puts "HITL event created: #{event.id} #{event.title}#{folder_info}"
55
79
  puts " Path: #{event.file_path}"
80
+ if event.recipient
81
+ puts " Recipient: #{Atoms::HitlAddress.to_s(event.recipient)}"
82
+ end
83
+ if event.reply_to
84
+ puts " Reply-To: #{Atoms::HitlAddress.to_s(event.reply_to)}"
85
+ end
56
86
  end
57
87
 
58
88
  private
@@ -64,6 +94,45 @@ module Ace
64
94
  raise Ace::Support::Cli::Error.new("Invalid kind '#{kind}'. Allowed: #{allowed.join(", ")}")
65
95
  end
66
96
 
97
+ def validate_type!(type)
98
+ allowed = %w[question report]
99
+ return if allowed.include?(type)
100
+
101
+ raise Ace::Support::Cli::Error.new("Invalid type '#{type}'. Allowed: #{allowed.join(", ")}")
102
+ end
103
+
104
+ def validate_result!(result)
105
+ allowed = %w[success error]
106
+ return if allowed.include?(result)
107
+
108
+ raise Ace::Support::Cli::Error.new("Invalid result '#{result}'. Allowed: #{allowed.join(", ")}")
109
+ end
110
+
111
+ def validate_combinations!(type, result:, body:, questions:, resume:, kind: nil)
112
+ report = type == "report"
113
+ if report
114
+ unless result
115
+ raise Ace::Support::Cli::Error.new("--type report requires --result success|error")
116
+ end
117
+ if questions.any?
118
+ raise Ace::Support::Cli::Error.new("--question cannot be combined with --type report; use --body lines")
119
+ end
120
+ if resume
121
+ raise Ace::Support::Cli::Error.new("--resume cannot be combined with --type report; reports do not resume")
122
+ end
123
+ if kind
124
+ raise Ace::Support::Cli::Error.new("--kind cannot be combined with --type report; kinds apply to questions only")
125
+ end
126
+ else
127
+ if result
128
+ raise Ace::Support::Cli::Error.new("--result is only valid with --type report")
129
+ end
130
+ if body.any?
131
+ raise Ace::Support::Cli::Error.new("--body is only valid with --type report")
132
+ end
133
+ end
134
+ end
135
+
67
136
  def parse_tags(raw)
68
137
  return [] unless raw
69
138
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../../providers/lab"
4
5
 
5
6
  module Ace
6
7
  module Hitl
@@ -13,17 +14,10 @@ module Ace
13
14
  # are one JSON line, byte-compatible with the migrated CLI
14
15
  # contract.
15
16
  module LifecycleCommand
16
- STDIN_READER = ->(limit) { $stdin.read(limit) }.freeze
17
-
18
17
  def lifecycle_client
19
18
  Providers::Lab.boundary_client
20
19
  end
21
20
 
22
- def overseer
23
- channel_root = ENV.fetch("ACE_HITL_OVERSEER_CHANNEL_ROOT", "/lab/state/overseer-channel")
24
- Lifecycle::Overseer.new(outbox_dir: File.join(channel_root, "outbox"))
25
- end
26
-
27
21
  def emit(result)
28
22
  puts JSON.generate(result)
29
23
  end
@@ -14,6 +14,8 @@ module Ace
14
14
 
15
15
  option :status, type: :string, aliases: %w[-s], desc: "Filter by status"
16
16
  option :kind, type: :string, aliases: %w[-k], desc: "Filter by kind"
17
+ option :type, type: :string, desc: "Filter by type (question, report)"
18
+ option :delivery, type: :string, desc: "Filter by delivery state (undelivered, delivered)"
17
19
  option :tags, type: :string, aliases: %w[-T], desc: "Filter by tags (comma-separated)"
18
20
  option :in, type: :string, aliases: %w[-i], desc: "Folder filter (next, all, archive)"
19
21
  option :scope, type: :string, desc: "Scope (current, all)"
@@ -26,11 +28,16 @@ module Ace
26
28
  scope = validate_scope(options[:scope])
27
29
  status = options[:status]
28
30
  kind = options[:kind]
31
+ type = options[:type]
32
+ validate_type!(type) if type
33
+ delivery = options[:delivery]
34
+ validate_delivery!(delivery) if delivery
29
35
  tags = parse_tags(options[:tags])
30
36
  in_folder = options[:in] || "next"
31
37
 
32
38
  manager = Ace::Hitl::Organisms::HitlManager.new
33
- events = manager.list(status: status, kind: kind, tags: tags, in_folder: in_folder, scope: scope)
39
+ events = manager.list(status: status, kind: kind, type: type, delivery: delivery,
40
+ tags: tags, in_folder: in_folder, scope: scope)
34
41
 
35
42
  puts Ace::Hitl::Molecules::HitlDisplayFormatter.format_list(
36
43
  events,
@@ -47,6 +54,20 @@ module Ace
47
54
  raw.split(",").map(&:strip).reject(&:empty?)
48
55
  end
49
56
 
57
+ def validate_type!(type)
58
+ allowed = %w[question report]
59
+ return if allowed.include?(type)
60
+
61
+ raise Ace::Support::Cli::Error.new("Invalid type '#{type}'. Allowed: #{allowed.join(", ")}")
62
+ end
63
+
64
+ def validate_delivery!(delivery)
65
+ allowed = %w[undelivered delivered]
66
+ return if allowed.include?(delivery)
67
+
68
+ raise Ace::Support::Cli::Error.new("Invalid delivery filter '#{delivery}'. Allowed: #{allowed.join(", ")}")
69
+ end
70
+
50
71
  def validate_scope(raw_scope)
51
72
  return nil if raw_scope.nil?
52
73