ace-hitl 0.25.1 → 0.27.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: a02c62f4939a60bf11cecc65e359987ef263fe520191bf42ac806326eb9cebaf
4
- data.tar.gz: 998d00803a4a9b88e3f92bdd506288e4ca536339dd9cb328a9d79568be689e79
3
+ metadata.gz: b03d376ceb86f080d28d686be092bc972d05fa6a03037ba45181ad4bcef124ba
4
+ data.tar.gz: eebcbcc9d94d2c4f929eb4a0e6559812170bc2c45c9c2da67fb4ed7fc7eaf2d9
5
5
  SHA512:
6
- metadata.gz: c5745f91860ee25c500cecdbaff63efec395c4588cd064fa0df225a8844abdadf4a3f8ab10ba8fde9a83817212a3924a799b2909b79ea244a68c4d7df2dbb46f
7
- data.tar.gz: 64a33f6d3de1f218fd60702a0c37072b95688fef66c853b8c8c02f1936108ff156e79f8761a70bb4e9b6c263288de77a7b2478469ce494be53d7198351adad7e
6
+ metadata.gz: 219f51b837aa9f69beb1faaaafdea4327bce68203e9a3fbcdce9d02d85d1c503984abe6d2855ad1643b94f3fdc115142718410f47dd031a98dfeb19d489a4a64
7
+ data.tar.gz: d149583ddc5c607e45b15bab620ae41716ebb764fb0e90807252747647edd6e3df1c1d935f171772ea28db1ca9d7bd7a78aaadfffb32a9ea6e0be7bcedce52fa
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.0] - 2026-11-12
11
+
12
+ ### Changed
13
+
14
+ - Close applied decisions with an atomic lesson and expose consistent semantic report revisions.
15
+ - Include the recipient in semantic observation for cross-project ownership filtering.
16
+
17
+
18
+ ## [0.26.0] - 2026-11-12
19
+
20
+ ### Added
21
+
22
+ - Expose canonical local case cancellation through `cancel --case --reason`, preserving audited reasons and idempotence without fabricating human answers or contacting relay services.
23
+
10
24
  ## [0.25.1] - 2026-11-10
11
25
 
12
26
  ### Fixed
@@ -165,3 +165,16 @@ Canonical lifecycle namespace: `hitl.event.*`
165
165
  - Blockers always produce canonical `HITL: <id> <path>` failure reason.
166
166
  - Human-required completion handoffs generate approval HITL events.
167
167
  - Resolved HITL events are archived only after successful resume dispatch.
168
+
169
+ ## Withdraw an obsolete local question
170
+
171
+ If the reason for an unanswered or unapplied question no longer exists, cancel
172
+ the exact local case with the evidence. Do not manufacture a human answer:
173
+
174
+ ```bash
175
+ ace-hitl cancel <case-id> --case --reason "Obsolete: verified new review commit and active next round"
176
+ ```
177
+
178
+ This records terminal cancellation in the canonical case. Repetition is a no-op;
179
+ an applied effect must be closed instead. Without `--case`, cancel addresses the
180
+ relay request owner; it never guesses a different owner on lookup failure.
@@ -14,16 +14,29 @@ module Ace
14
14
  include Ace::Support::Cli::Base
15
15
  include LifecycleCommand
16
16
 
17
- desc "Cancel one HITL relay request with an audited reason"
17
+ desc "Cancel a relay request or an explicit local case with an audited reason"
18
18
 
19
19
  argument :id, required: true, desc: "HITL relay request id"
20
20
 
21
+ option :case, type: :boolean, desc: "Cancel an exact local case instead of a relay request; requires --reason"
22
+
21
23
  option :reason, type: :string, desc: "Audited reason recorded in the public lifecycle record"
22
24
 
23
25
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
24
26
 
25
27
  def call(id:, **options)
26
- emit(lifecycle_client.cancel(id, reason: options[:reason] || ""))
28
+ if options[:case]
29
+ reason = options[:reason].to_s.strip
30
+ raise Ace::Support::Cli::Error.new("local case cancellation requires --reason") if reason.empty?
31
+ state = Organisms::HitlManager.new.cancel_case(id, reason: reason)
32
+ emit({"case" => id, "state" => state.to_s})
33
+ else
34
+ emit(lifecycle_client.cancel(id, reason: options[:reason] || ""))
35
+ end
36
+ rescue Organisms::HitlManager::UnknownCaseError,
37
+ Organisms::HitlManager::AmbiguousReferenceError,
38
+ Organisms::HitlManager::LifecycleTransitionError => e
39
+ raise_lifecycle_error("not cancelled: #{e.message}")
27
40
  rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
28
41
  raise_lifecycle_error(e.message)
29
42
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "yaml"
4
5
 
5
6
  module Ace
6
7
  module Hitl
@@ -20,14 +21,17 @@ module Ace
20
21
 
21
22
  argument :case_id, required: true, desc: "Exact HITL case id"
22
23
 
24
+ option :lesson_file, desc: "YAML lesson: by, relation, takeaway, optional refs"
23
25
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
24
26
 
25
- def call(case_id:, **)
26
- state = Ace::Hitl::Organisms::HitlManager.new.close_case(case_id)
27
+ def call(case_id:, lesson_file: nil, **)
28
+ lesson = lesson_file ? YAML.safe_load_file(lesson_file, aliases: false) : nil
29
+ state = Ace::Hitl::Organisms::HitlManager.new.close_case(case_id, lesson: lesson)
27
30
  puts JSON.generate({"case" => case_id, "state" => state.to_s})
28
31
  rescue Ace::Hitl::Organisms::HitlManager::UnknownCaseError,
29
32
  Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError,
30
- Ace::Hitl::Organisms::HitlManager::LifecycleTransitionError => e
33
+ Ace::Hitl::Organisms::HitlManager::LifecycleTransitionError,
34
+ Ace::Hitl::Organisms::HitlManager::LessonRefusedError, Psych::Exception, SystemCallError => e
31
35
  raise Ace::Support::Cli::Error.new("not closed: #{e.message}")
32
36
  end
33
37
  end
data/lib/ace/hitl/cli.rb CHANGED
@@ -47,7 +47,7 @@ module Ace
47
47
  ["wait", "Wait for an answer on a specific HITL event"],
48
48
  ["deliver", "Deliver an answer (stdin) to a pending HITL relay request"],
49
49
  ["consume", "Wait for and consume the answer of one own HITL relay request"],
50
- ["cancel", "Cancel one HITL relay request with an audited reason"],
50
+ ["cancel", "Cancel a relay request or explicit local case (--case)"],
51
51
  ["pending", "List answerable HITL relay requests (transport)"],
52
52
  ["states", "List public HITL lifecycle projections (transport)"],
53
53
  ["duty", "Project pending and escalated HITL requests (transport)"],
@@ -15,19 +15,23 @@ module Ace
15
15
 
16
16
  def call(scope: "current", limit: 500, owner: nil, in_folder: "next")
17
17
  events = incoming_events(scope: scope, limit: limit, owner: owner, in_folder: in_folder)
18
- rows = events.map do |event|
19
- semantic = [event.id, event.type, event.status, event.result, event.kind,
20
- event.title, event.content, event.answer, event.questions,
21
- Array(event.tags).sort, canonical(event.recipient), canonical(event.reply_to),
22
- event.metadata.values_at("task", "assignment", "step", "step_name", "resume_instructions")]
23
- {"key" => Digest::SHA256.hexdigest(event.file_path.to_s),
24
- "id" => event.id, "revision" => Digest::SHA256.hexdigest(JSON.generate(canonical(semantic))),
25
- "type" => event.type, "status" => event.status, "result" => event.result,
26
- "task" => event.metadata["task"], "assignment" => event.metadata["assignment"]}
27
- end
18
+ rows = events.map { |event| project(event) }
28
19
  {"schema" => "ace.hitl.observation/v1", "events" => rows.sort_by { |row| row["key"] }}
29
20
  end
30
21
 
22
+ # Project the same immutable event read by the report consumer.
23
+ def project(event)
24
+ semantic = [event.id, event.type, event.status, event.result, event.kind,
25
+ event.title, event.content, event.answer, event.questions,
26
+ Array(event.tags).sort, canonical(event.recipient), canonical(event.reply_to),
27
+ event.metadata.values_at("task", "assignment", "role", "step", "step_name", "resume_instructions")]
28
+ {"key" => Digest::SHA256.hexdigest(event.file_path.to_s),
29
+ "id" => event.id, "revision" => Digest::SHA256.hexdigest(JSON.generate(canonical(semantic))),
30
+ "type" => event.type, "status" => event.status, "result" => event.result,
31
+ "task" => event.metadata["task"], "assignment" => event.metadata["assignment"],
32
+ "recipient" => event.recipient}
33
+ end
34
+
31
35
  # Shared read boundary for observation and report consumers. Preserve
32
36
  # strict enumeration and recipient ownership in both paths.
33
37
  def incoming_events(scope: "current", limit: 500, owner: nil, in_folder: "next")
@@ -238,27 +238,27 @@ module Ace
238
238
  # close is a no-op; a close without the applied proof fails —
239
239
  # exactly-once belongs to the EFFECT, never to the close itself.
240
240
  # @return [Symbol] :closed or :duplicate
241
- def close_case(ref, scope: nil)
241
+ def close_case(ref, scope: nil, lesson: nil)
242
242
  event = exact_case(ref, scope: scope)
243
- ensure_answered!(event)
244
-
245
- transition(event.file_path) do |frontmatter, _body|
243
+ rewrite_case(event.file_path) do |frontmatter, body|
246
244
  application = frontmatter["application"] || {}
247
- case application["state"].to_s
248
- when "", "pending"
245
+ next [:duplicate, nil, nil] if application["state"] == "closed"
246
+ unless frontmatter["answered"] == true && application["state"] == "applied"
249
247
  raise LifecycleTransitionError,
250
- "case '#{event.id}' has no applied effect; close only after the " \
251
- "applied proof (ace-hitl apply CASE --ref OWNER_REF)"
252
- when "applied"
253
- frontmatter["application"] = application.merge("state" => "closed",
254
- "closed_at" => timestamp)
255
- :closed
256
- when "closed"
257
- :duplicate
258
- else
259
- raise LifecycleTransitionError,
260
- "case '#{event.id}' is #{application["state"]}; it cannot be closed"
248
+ "case '#{event.id}' has no applied effect from a human decision; close only after ace-hitl apply CASE --ref OWNER_REF"
261
249
  end
250
+ if lesson
251
+ unless lesson.is_a?(Hash) && (lesson.keys.map(&:to_s) - %w[by relation takeaway refs]).empty?
252
+ raise LessonRefusedError, "lesson must contain by, relation, takeaway and optional refs"
253
+ end
254
+ fields = lesson.transform_keys(&:to_sym)
255
+ body = lesson_body(event.id, frontmatter, body, **fields)
256
+ end
257
+ if body.include?("## Suggestion") && !body.include?("## Lesson")
258
+ raise LessonRefusedError, "case '#{event.id}' has a suggestion; supply a lesson before closing"
259
+ end
260
+ frontmatter["application"] = application.merge("state" => "closed", "closed_at" => timestamp)
261
+ [:closed, frontmatter, body]
262
262
  end
263
263
  end
264
264
 
@@ -267,7 +267,7 @@ module Ace
267
267
  # Only from the not-yet-applied states — an applied effect is
268
268
  # history and is closed, not cancelled.
269
269
  # @return [Symbol] :cancelled or :duplicate
270
- def cancel_case(ref, scope: nil)
270
+ def cancel_case(ref, scope: nil, reason: nil)
271
271
  event = exact_case(ref, scope: scope)
272
272
 
273
273
  transition(event.file_path) do |frontmatter, _body|
@@ -276,6 +276,7 @@ module Ace
276
276
  when "", "pending"
277
277
  frontmatter["application"] = {"state" => "cancelled",
278
278
  "cancelled_at" => timestamp}
279
+ frontmatter["application"]["reason"] = reason.to_s.strip unless reason.to_s.strip.empty?
279
280
  :cancelled
280
281
  when "cancelled"
281
282
  :duplicate
@@ -353,40 +354,33 @@ module Ace
353
354
  # @return [Symbol] :written or :duplicate
354
355
  def add_lesson(ref, by:, relation:, takeaway:, refs: [], scope: nil)
355
356
  event = exact_case(ref, scope: scope)
356
-
357
357
  rewrite_case(event.file_path) do |frontmatter, body|
358
358
  refuse_terminal!(frontmatter, event.id)
359
- unless body.include?("## Suggestion")
360
- raise LessonRefusedError,
361
- "case '#{event.id}' carries no suggestion; there is nothing to compare"
362
- end
363
- unless frontmatter["answered"] == true
364
- raise LessonRefusedError,
365
- "case '#{event.id}' has no accepted human decision to learn from"
366
- end
367
-
368
- if body.include?("## Lesson")
369
- next [:duplicate, nil, nil] if lesson_matches?(body, by, takeaway)
370
-
371
- raise LessonRefusedError,
372
- "case '#{event.id}' already carries a lesson; reprocessing does not multiply them"
373
- end
374
-
375
- unless %w[accepted rejected modified].include?(relation.to_s)
376
- raise LessonRefusedError,
377
- "relation must be accepted, rejected or modified (got '#{relation}')"
378
- end
379
-
380
- decision_refs = ["case:#{event.id}", "suggestion:#{event.id}-s1"]
381
- decision_refs << "decision:#{frontmatter["answered_at"]}" if frontmatter["answered_at"]
382
- lesson = {"id" => "#{event.id}-l1", "author" => by.to_s,
383
- "relation" => relation.to_s, "takeaway" => takeaway.to_s.strip,
384
- "refs" => Array(refs).map(&:to_s).reject(&:empty?).push(*decision_refs).uniq,
385
- "at" => timestamp}
386
- updated = Molecules::HitlLessonEditor.apply(body, lesson)
359
+ updated = lesson_body(event.id, frontmatter, body,
360
+ by: by, relation: relation, takeaway: takeaway, refs: refs)
361
+ updated == body ? [:duplicate, nil, nil] : [:written, frontmatter, updated]
362
+ end
363
+ end
387
364
 
388
- [:written, frontmatter, updated]
365
+ private def lesson_body(id, frontmatter, body, by: nil, relation: nil, takeaway: nil, refs: [])
366
+ unless body.include?("## Suggestion") && frontmatter["answered"] == true
367
+ raise LessonRefusedError, "case '#{id}' has no suggestion or no accepted human decision"
368
+ end
369
+ unless %w[accepted rejected modified].include?(relation.to_s) &&
370
+ !by.to_s.strip.empty? && !takeaway.to_s.strip.empty? && refs.is_a?(Array)
371
+ raise LessonRefusedError, "lesson requires by, takeaway, relation accepted/rejected/modified and an array of refs"
372
+ end
373
+ if body.include?("## Lesson")
374
+ return body if lesson_matches?(body, by, takeaway) &&
375
+ body_section(body, "Lesson").match?(/^relation: #{Regexp.escape(relation.to_s)}$/)
376
+ raise LessonRefusedError, "case '#{id}' already carries a lesson; reprocessing does not multiply them"
389
377
  end
378
+ decision_refs = ["case:#{id}", "suggestion:#{id}-s1"]
379
+ decision_refs << "decision:#{frontmatter['answered_at']}" if frontmatter["answered_at"]
380
+ lesson = {"id" => "#{id}-l1", "author" => by.to_s,
381
+ "relation" => relation.to_s, "takeaway" => takeaway.to_s.strip,
382
+ "refs" => refs.map(&:to_s).reject(&:empty?).push(*decision_refs).uniq, "at" => timestamp}
383
+ Molecules::HitlLessonEditor.apply(body, lesson)
390
384
  end
391
385
 
392
386
  # Decision history for the advisory agent's NEXT suggestion (task
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Hitl
5
- VERSION = "0.25.1"
5
+ VERSION = "0.27.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.25.1
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-11-10 00:00:00.000000000 Z
10
+ date: 2026-11-12 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-hitl-contract