ace-hitl 0.26.0 → 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: 64cb514390029a16b5a139d1d0db8da63b36c8fc08b21df9525d2f84bb94c1c3
4
- data.tar.gz: 20118ea180abf1437a63d32c48c72581ca6e0aa115b79422b83f77aabc9afc04
3
+ metadata.gz: b03d376ceb86f080d28d686be092bc972d05fa6a03037ba45181ad4bcef124ba
4
+ data.tar.gz: eebcbcc9d94d2c4f929eb4a0e6559812170bc2c45c9c2da67fb4ed7fc7eaf2d9
5
5
  SHA512:
6
- metadata.gz: e096433ca99b4cbc9455f21494d42d2cb08bc4dcf305e8e14d1e4c516a470ab8aa6a6e3eb723c3cb142570dc661197695cc30bb088c6c0713698ea6eda390446
7
- data.tar.gz: d23bcb8c437f671d91d3661e3f27041939b4b3007acf680e19bb0c2c3315dd13f8250197cc60501786d4f7f875d20913db58c43694a65009f1442341f4a7982b
6
+ metadata.gz: 219f51b837aa9f69beb1faaaafdea4327bce68203e9a3fbcdce9d02d85d1c503984abe6d2855ad1643b94f3fdc115142718410f47dd031a98dfeb19d489a4a64
7
+ data.tar.gz: d149583ddc5c607e45b15bab620ae41716ebb764fb0e90807252747647edd6e3df1c1d935f171772ea28db1ca9d7bd7a78aaadfffb32a9ea6e0be7bcedce52fa
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ 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
+
10
18
  ## [0.26.0] - 2026-11-12
11
19
 
12
20
  ### Added
@@ -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
@@ -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
 
@@ -354,40 +354,33 @@ module Ace
354
354
  # @return [Symbol] :written or :duplicate
355
355
  def add_lesson(ref, by:, relation:, takeaway:, refs: [], scope: nil)
356
356
  event = exact_case(ref, scope: scope)
357
-
358
357
  rewrite_case(event.file_path) do |frontmatter, body|
359
358
  refuse_terminal!(frontmatter, event.id)
360
- unless body.include?("## Suggestion")
361
- raise LessonRefusedError,
362
- "case '#{event.id}' carries no suggestion; there is nothing to compare"
363
- end
364
- unless frontmatter["answered"] == true
365
- raise LessonRefusedError,
366
- "case '#{event.id}' has no accepted human decision to learn from"
367
- end
368
-
369
- if body.include?("## Lesson")
370
- next [:duplicate, nil, nil] if lesson_matches?(body, by, takeaway)
371
-
372
- raise LessonRefusedError,
373
- "case '#{event.id}' already carries a lesson; reprocessing does not multiply them"
374
- end
375
-
376
- unless %w[accepted rejected modified].include?(relation.to_s)
377
- raise LessonRefusedError,
378
- "relation must be accepted, rejected or modified (got '#{relation}')"
379
- end
380
-
381
- decision_refs = ["case:#{event.id}", "suggestion:#{event.id}-s1"]
382
- decision_refs << "decision:#{frontmatter["answered_at"]}" if frontmatter["answered_at"]
383
- lesson = {"id" => "#{event.id}-l1", "author" => by.to_s,
384
- "relation" => relation.to_s, "takeaway" => takeaway.to_s.strip,
385
- "refs" => Array(refs).map(&:to_s).reject(&:empty?).push(*decision_refs).uniq,
386
- "at" => timestamp}
387
- 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
388
364
 
389
- [: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"
390
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)
391
384
  end
392
385
 
393
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.26.0"
5
+ VERSION = "0.27.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-hitl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz