ace-hitl 0.24.2 → 0.25.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.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/items"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Molecules
8
+ # The one crash-atomic writer for HITL case files (re-review R2):
9
+ # every mutation locks a STABLE sidecar (`<case>.lock`, a file that
10
+ # is never renamed) and replaces the case file via tmp+fsync+rename.
11
+ # A process killed mid-write leaves either the old or the new
12
+ # content — never a truncated or zero-byte case — and mutual
13
+ # exclusion survives the rename (a lock on the case file itself
14
+ # would break the moment its inode is replaced).
15
+ module CaseFileWriter
16
+ module_function
17
+
18
+ # Exclusive sidecar lock around a case-file mutation.
19
+ def with_case_lock(file_path)
20
+ File.open("#{file_path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
21
+ lock.flock(File::LOCK_EX)
22
+ yield
23
+ end
24
+ end
25
+
26
+ # Crash-atomic replace of the whole case file (fsynced tmp +
27
+ # rename). Callers hold the sidecar lock.
28
+ def write_atomic(file_path, new_content)
29
+ tmp_path = "#{file_path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
30
+ File.open(tmp_path, "w", 0o600) do |tmp|
31
+ tmp.write(new_content)
32
+ tmp.flush
33
+ tmp.fsync
34
+ end
35
+ File.rename(tmp_path, file_path)
36
+ ensure
37
+ File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
38
+ end
39
+
40
+ # Field-only update under the sidecar lock: one locked
41
+ # read-modify-write, replaced atomically — never the shared
42
+ # FieldUpdater's in-place truncate(0) write.
43
+ def apply_fields(file_path, set: {}, add: {}, remove: {})
44
+ with_case_lock(file_path) do
45
+ content = File.read(file_path)
46
+ begin
47
+ frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
48
+ rescue Psych::Exception => e
49
+ # Same error shape as FieldUpdater.parse_strict: a corrupt
50
+ # case file is a named, path-carrying refusal to rewrite —
51
+ # never a raw parser error escaping the writer.
52
+ raise ArgumentError, "#{file_path}: refusing to rewrite the file (#{e.message})"
53
+ end
54
+ # Strip the body's leading newline: rebuild re-inserts the
55
+ # frontmatter/body separator, so keeping it would add one
56
+ # blank line per write — compounding on every field update.
57
+ body = body.sub(/\A\n/, "")
58
+ Ace::Support::Items::Molecules::FieldUpdater.apply_set(frontmatter, set)
59
+ Ace::Support::Items::Molecules::FieldUpdater.apply_add(frontmatter, add)
60
+ Ace::Support::Items::Molecules::FieldUpdater.apply_remove(frontmatter, remove)
61
+ write_atomic(file_path,
62
+ Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body))
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -29,6 +29,7 @@ module Ace
29
29
  step: nil,
30
30
  step_name: nil,
31
31
  resume_instructions: nil,
32
+ secret: nil,
32
33
  move_to: nil,
33
34
  recipient: nil,
34
35
  reply_to: nil,
@@ -80,6 +81,7 @@ module Ace
80
81
  "step_name" => step_name,
81
82
  "resume_instructions" => resume_instructions,
82
83
  "answered" => false,
84
+ "secret" => secret == true ? true : nil,
83
85
  "created_at" => time
84
86
  }.merge(recipient_keys(recipient, id))
85
87
  .merge(reply_to_keys(reply_to, id))
@@ -91,7 +93,10 @@ module Ace
91
93
  content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body_text)
92
94
 
93
95
  item_file = File.join(item_dir, Atoms::HitlFilePattern.filename(id, file_slug))
94
- File.write(item_file, content)
96
+ # 0600 from the first byte: a HITL case can carry secret
97
+ # material (secret asks), so it is never world-readable — even
98
+ # between creation and any later hardening write.
99
+ File.open(item_file, "w", 0o600) { |file| file.write(content) }
95
100
 
96
101
  loader = HitlLoader.new
97
102
  special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Molecules
6
+ # Lesson editor (task 8xs.t.pu0.c): the takeaway the advisory agent
7
+ # draws from comparing the human decision with its suggestion — its
8
+ # OWN `## Lesson` section at the same case, inserted BEFORE the
9
+ # `## Answer` section like the suggestion (answer rewrites can never
10
+ # remove history). A lesson references the exact question/suggestion/
11
+ # decision revisions it compared; it is not a new decision and never
12
+ # changes the answer's authorship.
13
+ class HitlLessonEditor
14
+ ANSWER_HEADER = /^## Answer\s*$/m
15
+
16
+ # @param body [String] current case body
17
+ # @param lesson [Hash] {"id","author","relation","takeaway",
18
+ # "refs","at"}
19
+ def self.apply(body, lesson)
20
+ block = render(lesson)
21
+ source = body.to_s.rstrip
22
+ return block if source.empty?
23
+
24
+ if source.match?(ANSWER_HEADER)
25
+ source.sub(ANSWER_HEADER) { "#{block}\n\n## Answer" }
26
+ else
27
+ "#{source}\n\n#{block}"
28
+ end
29
+ end
30
+
31
+ def self.render(lesson)
32
+ lines = ["## Lesson"]
33
+ lines << "author: #{lesson.fetch("author")}"
34
+ lines << "at: #{lesson.fetch("at")}"
35
+ lines << "relation: #{lesson.fetch("relation")}"
36
+ lines << "refs: #{Array(lesson["refs"]).join(", ")}" if lesson["refs"]
37
+ lines << "" << "takeaway: #{lesson.fetch("takeaway")}"
38
+ lines.join("\n") + "\n"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Molecules
6
+ # Marked-suggestion editor (task 8xs.t.pu0.b): a suggestion is its
7
+ # OWN section in the canonical case file — never the Answer. It is
8
+ # inserted BEFORE the `## Answer` section (or appended at the end
9
+ # when no answer exists), so the Answer editor's tail replacement
10
+ # can never remove a suggestion, and an answer written after a
11
+ # suggestion preserves it. Pure string surgery — atomicity and
12
+ # locking belong to the manager's transition helper.
13
+ class HitlSuggestionEditor
14
+ ANSWER_HEADER = /^## Answer\s*$/m
15
+ SUGGESTION_HEADER = "## Suggestion"
16
+
17
+ # @param body [String] current case body (frontmatter stripped)
18
+ # @param suggestion [Hash] {"id","author","text","rationale",
19
+ # "refs","at"}
20
+ # @return [String] the updated body
21
+ def self.apply(body, suggestion)
22
+ block = render(suggestion)
23
+ source = body.to_s.rstrip
24
+ return block if source.empty?
25
+
26
+ if source.match?(ANSWER_HEADER)
27
+ # Insert strictly before the Answer section: the answer tail
28
+ # replacement never touches what precedes it.
29
+ source.sub(ANSWER_HEADER) { "#{block}\n\n## Answer" }
30
+ else
31
+ "#{source}\n\n#{block}"
32
+ end
33
+ end
34
+
35
+ # @return [String] the rendered marked suggestion block
36
+ def self.render(suggestion)
37
+ lines = [SUGGESTION_HEADER]
38
+ lines << "author: #{suggestion.fetch("author")}"
39
+ lines << "at: #{suggestion.fetch("at")}"
40
+ lines << "refs: #{Array(suggestion["refs"]).join(", ")}" if suggestion["refs"]
41
+ lines << "" << "proposal: #{suggestion.fetch("text")}" << ""
42
+ lines << "rationale: #{suggestion.fetch("rationale")}"
43
+ lines.join("\n") + "\n"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end