ace-hitl 0.24.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d4e7dd5c819e98ea18006c02d1d6e587857d4889f91bd4d42fc2d0e26461d9d
4
- data.tar.gz: 4a05d425d80b079f03800fa9f7277412d8a2218f4b56d11a624df50baf4325eb
3
+ metadata.gz: f9115490bffe274fbc591fc0c0c3efb826c70fe549b17e7d57b5928e298ffe23
4
+ data.tar.gz: e14fa6ef9c4251fcad792f2c8cbdd362fe642b9c66dc7060fa621e6785f65d0a
5
5
  SHA512:
6
- metadata.gz: 292cafb6c572d818b1c0a3988a36bd588303dacfa357a585fa0166430db74e61c682ad1d45242dc44be9943110c3cf45546f9f965aee7861e75b10862b2559b2
7
- data.tar.gz: 03b92ef04274f643f84f2868d19f021ff4e72f0add38d164e4e11657ee1c532571aeb6751ab29b5fe67d8475a23352e195cf8f284b347985554a02f881c8d984
6
+ metadata.gz: b8f52aef4b90d868a62e1966523282ea1a74eabe20930034736e1a73a941c701df4c4580297659ea8c5255dc45676cf3f37b466c934faa5ce7d71e10cc2d4a5f
7
+ data.tar.gz: 202ca6825f02dcaec4da9fce88aaeef2ca66c33f98ef6cf51daa6585ec552dc7eeff8cfb3556ad35881aa0ccfa3cb13fc7653b68077a0ffbfcddbbf5b6e6f686
data/CHANGELOG.md CHANGED
@@ -5,7 +5,61 @@ All notable changes to `ace-hitl` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.25.0] - 2026-10-30
9
+
10
+ ### Fixed (review rounds 3/3b + re-review R2)
11
+
12
+ - Case writes are crash-atomic under a stable sidecar lock (`<case>.lock`)
13
+ through the shared `Molecules::CaseFileWriter`: every writer path (reply,
14
+ suggestion, lesson, lifecycle, field updates, delivery persistence,
15
+ resume notes) holds the sidecar across tmp+fsync+rename — the
16
+ flock+rename lost-update race, the in-place `truncate(0)` field update
17
+ (a killed process could zero the case and lose the answer; SIGKILL
18
+ regression test added), and unlocked writer paths are closed. Terminal
19
+ cases (closed/cancelled) refuse new answers, suggestions and lessons;
20
+ history capture is section-scoped; `history --format table`/`--quiet`
21
+ are real. Case files are created 0600 and every tmp write
22
+ is 0600 before rename; corrupt-file parses refuse with a named error
23
+ (Psych::Exception aligned across paths).
24
+
25
+ ### Added
26
+
27
+ - `ace-hitl lesson` + `HitlManager#add_lesson` and `ace-hitl history` +
28
+ `#decision_history` (task 8xs.t.pu0.c): learning from real decisions — one
29
+ Lesson section per case citing exact question/suggestion/decision
30
+ revisions (idempotent, never overwriting history, requiring a suggestion
31
+ to compare against), and a citable, scope-local, secret-free decision
32
+ history for the next suggestion.
33
+
34
+ - `ace-hitl suggest` and `HitlManager#add_suggestion` (task 8xs.t.pu0.b): the
35
+ Second Captain's marked advisory proposal — own section before the Answer
36
+ (survives answer rewrites), author/rationale/grounds recorded, one per case
37
+ (identical repeat = duplicate), refused on answered or secret cases, never
38
+ an answer or approval.
39
+
40
+ - `ace-hitl ask --secret` (task 8xs.t.pu0.9): the expected answer (e.g. an
41
+ OTP) is marked secret AT CREATION — no title heuristics; the case carries
42
+ `secret: true` and the armed wake request flags the bridge to wake with the
43
+ case reference only, never the value.
44
+
45
+ - Case lifecycle (task 8xs.t.pu0.6): `ace-hitl apply CASE --ref OWNER_REF`
46
+ records the applied effect with its owner reference (idempotent per
47
+ reference, conflicting references rejected), `ace-hitl close CASE` is
48
+ terminal only after the applied proof (duplicate no-op, closed cases never
49
+ re-open), `cancel_case` terminates without application, and
50
+ `ace-hitl sweep [--apply]` lists/removes ONLY the ask-wake request
51
+ leftovers of terminally closed/cancelled cases — active cases, unknown
52
+ ids and the canonical decision history are always preserved. Lifecycle
53
+ state lives in the canonical case file (receipt pending/received,
54
+ application pending/applied/closed/cancelled); transitions are locked and
55
+ conflict-rejecting.
56
+
57
+ - `ace-hitl reply CASE_ID --stdin` (task 8xs.t.pu0.5): receiver-side answer
58
+ delivery on the owner's machine. Output carries only the case and its state
59
+ (`received`/`duplicate`); the reference must be the exact case id (no
60
+ shortcut/latest resolution), identical redelivery is idempotent without a
61
+ rewrite, a conflicting answer never overwrites the decision, and empty
62
+ answers/reports/unknown references fail as explicit non-deliveries.
9
63
 
10
64
  ## [0.24.3] - 2026-10-29
11
65
 
data/docs/usage.md CHANGED
@@ -326,8 +326,13 @@ HITL event 8xhkep sent through 1 route(s):
326
326
  8xhkep
327
327
  ```
328
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
329
+ The final stdout line is always the bare case id. `--secret` marks the
330
+ EXPECTED answer as a secret (e.g. an OTP) at creation — the armed wake then
331
+ carries the CASE REFERENCE ONLY (the value never passes through the model;
332
+ the executor reads it from the case file through its secure channel), and a
333
+ rejected or expired secret answer needs a NEW case, never a replay. ask
334
+ never blocks and never polls, carries no urgency language, and has no
335
+ asker-side timeouts — the
331
336
  Captain replies whenever he wants. The answer is delivered TO the agent as
332
337
  an ace-wake wake carrying the answer content (see the ace-wake README's ask
333
338
  bridge contract); on wake, the agent acts immediately.
@@ -339,6 +344,102 @@ asynchronous). Exit 1 only for genuine rejects (a route refusing the
339
344
  frame, or a concrete configuration error); no wake request is dropped for
340
345
  a rejected ask.
341
346
 
347
+ ## Reply
348
+
349
+ `ace-hitl reply CASE_ID --stdin` (task 8xs.t.pu0.5) is the receiver-side
350
+ half of answer delivery: the transport (Hermes) runs it ON THE OWNER'S
351
+ MACHINE, in the owner's directory, with the answer text on stdin — never
352
+ as a shell command or an argv element:
353
+
354
+ ```bash
355
+ echo "Proceed with batch 2" | ace-hitl reply 8xhkep --stdin
356
+ {"case":"8xhkep","state":"received"}
357
+ ```
358
+
359
+ - The output names ONLY the case and its state: `received`, or `duplicate`
360
+ when the identical answer is redelivered (idempotent — the case file is
361
+ never rewritten). Success means ace-hitl confirmed the DURABLE write; a
362
+ reply frame sitting in a transport is not delivery.
363
+ - The reference must be the case's EXACT id from the ask — no shortcut or
364
+ latest-match resolution, so a reply can never land in a foreign case on
365
+ collision.
366
+ - Every failure is an explicit non-delivery with no side effects: an empty
367
+ answer, a report instead of a question, a conflicting (different)
368
+ answer — a reply never overwrites a human decision — or an unknown
369
+ reference all exit 1 without writing.
370
+ - The receiver does not need to be running: the answered case file is the
371
+ durable record; the armed ask wake consumes the change whenever the
372
+ asking session returns.
373
+
374
+ ## Suggest (Second Captain)
375
+
376
+ `ace-hitl suggest CASE_ID --by AUTHOR --text ... --rationale ... [--refs a,b]`
377
+ (task 8xs.t.pu0.b): the advisory Second Captain's MARKED proposal — its own
378
+ `## Suggestion` section in the exact case, with author, timestamp, grounds,
379
+ proposal and rationale, inserted BEFORE the `## Answer` section so an answer
380
+ rewrite can never remove it. It is not an Answer, never satisfies a gate,
381
+ and never acts in the human's name: the human decision always outranks it.
382
+
383
+ - One proposal per case: an identical repeat reports `duplicate` (no-op); a
384
+ different second proposal is refused.
385
+ - A case the human already answered refuses the suggestion ("discard the
386
+ late suggestion") without writing; a case marked `secret` (OTP) is never
387
+ delegated to the advisor.
388
+ - Shortcuts/unknown ids are refused exactly like `reply`.
389
+
390
+ ## Lesson & History (Second Captain learning)
391
+
392
+ `ace-hitl lesson CASE_ID --by AGENT --relation accepted|rejected|modified
393
+ --takeaway ...` (task 8xs.t.pu0.c): after the human answers a case that
394
+ carried a suggestion, the advisory agent writes ONE `## Lesson` section at
395
+ the same case — its comparison takeaway with references to the exact
396
+ question/suggestion/decision revisions, its own author and time. The lesson
397
+ never overwrites the question, suggestion or decision, never changes the
398
+ answer's author, and reprocessing is idempotent (an identical repeat is a
399
+ `duplicate`; a second different lesson is refused). A case without a
400
+ suggestion has nothing to compare — refused, never a fabricated agreement.
401
+
402
+ `ace-hitl history [--scope current] [--limit N]`: the citable decision
403
+ history for the NEXT suggestion — answered, NON-SECRET question cases of
404
+ this scope, each row with its checkable references (case id, suggestion,
405
+ decision, lesson+relation). Secret/OTP cases are filtered out before
406
+ anything reaches a model; there is no embedding store, decision index, or
407
+ corpus copy — HITL stays the single source, and the wider lab overseer's
408
+ access is not a license to share other projects' history.
409
+
410
+ ## Case lifecycle
411
+
412
+ The canonical case file carries the whole lifecycle (task 8xs.t.pu0.6) —
413
+ there is no second registry:
414
+
415
+ - receipt: `pending` → `received` (the answer was durably delivered);
416
+ - application: `pending` → `applied` → `closed`, or `cancelled` (terminal
417
+ without application). Reports stay `recorded` — they never await an
418
+ answer or application.
419
+
420
+ ```bash
421
+ ace-hitl apply 8xhkep --ref pr:398 # the effect owner reference verified by the consumer
422
+ ace-hitl close 8xhkep # terminal, only after the applied proof
423
+ ace-hitl sweep # list transport leftovers of terminal cases
424
+ ace-hitl sweep --apply # remove ONLY the proven leftovers
425
+ ```
426
+
427
+ Exactly-once belongs to the EFFECT, not the notification count: after a
428
+ crash between effect and `applied`, the consumer first re-checks the effect
429
+ at its owner (PR/release/assignment); an undecidable outcome is an explicit
430
+ reconciliation block, never a blind retry. Re-applying the same effect
431
+ reference is a no-op (`duplicate`); a different reference for an applied
432
+ case is a conflict. A duplicate close is a no-op. A late different answer
433
+ was already rejected at delivery, and a closed case never re-opens — an
434
+ explicit decision change needs a NEW case.
435
+
436
+ `ace-hitl sweep` qualifies the ask-wake request files against the canonical
437
+ cases: deletion candidates are ONLY requests whose case is terminally
438
+ `closed` or `cancelled`. Active cases (pending/received/applied), unknown
439
+ ids, unparseable requests and the canonical decision history (including the
440
+ history Second Captain learns from) are always preserved; without `--apply`
441
+ the command only lists.
442
+
342
443
  ## Lifecycle event names
343
444
 
344
445
  HITL lifecycle signals use this namespace:
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # `ace-hitl apply CASE --ref OWNER_REF` (task 8xs.t.pu0.6):
10
+ # record that the answer's effect was applied, WITH the
11
+ # reference to the effect owner (task/assignment/PR) the
12
+ # consumer verified first. Exactly-once belongs to the effect:
13
+ # after a crash the consumer re-checks the effect at its owner
14
+ # before calling this; an undecidable outcome is an explicit
15
+ # reconciliation block, never a blind retry. Close happens only
16
+ # after this proof exists.
17
+ class Apply < Ace::Support::Cli::Command
18
+ include Ace::Support::Cli::Base
19
+
20
+ desc "Record the answer's applied effect with its owner reference"
21
+
22
+ example ['ace-hitl apply 8xhkep --ref pr:398']
23
+
24
+ argument :case_id, required: true, desc: "Exact HITL case id"
25
+
26
+ option :ref, type: :string, required: true,
27
+ desc: "Effect owner reference (task/assignment/PR) verified by the consumer"
28
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
29
+
30
+ def call(case_id:, ref:, **)
31
+ state = Ace::Hitl::Organisms::HitlManager.new.apply_answer_effect(case_id, ref)
32
+ puts JSON.generate({"case" => case_id, "state" => state.to_s})
33
+ rescue Ace::Hitl::Organisms::HitlManager::UnknownCaseError,
34
+ Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError,
35
+ Ace::Hitl::Organisms::HitlManager::NotAnswerableError,
36
+ Ace::Hitl::Organisms::HitlManager::LifecycleTransitionError => e
37
+ raise Ace::Support::Cli::Error.new("not applied: #{e.message}")
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -36,6 +36,8 @@ module Ace
36
36
  option :question, type: :string, repeat: true, aliases: %w[-Q], desc: "Question line (repeatable)"
37
37
  option :task, type: :string, desc: "Task reference"
38
38
  option :tags, type: :string, aliases: %w[-T], desc: "Comma-separated tags"
39
+ option :secret, type: :boolean, default: false,
40
+ desc: "The expected answer is a secret (e.g. OTP): the wake carries the case reference only, never the value"
39
41
 
40
42
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
41
43
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
@@ -78,7 +80,8 @@ module Ace
78
80
  type: "question",
79
81
  task: options[:task],
80
82
  questions: Array(options[:question]).map(&:strip).reject(&:empty?),
81
- tags: parse_tags(options[:tags])
83
+ tags: parse_tags(options[:tags]),
84
+ secret: options[:secret] == true
82
85
  )
83
86
  end
84
87
 
@@ -111,8 +114,11 @@ module Ace
111
114
 
112
115
  # Drop the wake request the ace-wake extension consumes through its
113
116
  # 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.
117
+ # when the answer lands, the wake carries the answer content —
118
+ # EXCEPT for a secret ask (8xs.t.pu0.9), where the wake carries the
119
+ # case reference only and the executor reads the value from the
120
+ # case file through its secure channel. The atomic replace keeps
121
+ # the consumer from reading a torn request.
116
122
  def arm_wake(event, manager)
117
123
  requests_dir = File.join(File.expand_path(manager.root_dir), "wake-requests")
118
124
  FileUtils.mkdir_p(requests_dir)
@@ -122,6 +128,7 @@ module Ace
122
128
  "case_id" => event.id,
123
129
  "case_path" => File.expand_path(event.file_path)
124
130
  }
131
+ request["secret"] = true if event.metadata["secret"] == true
125
132
  temporary = File.join(requests_dir, ".#{event.id}-#{SecureRandom.hex(8)}.tmp")
126
133
  final = File.join(requests_dir, "#{event.id}.json")
127
134
  File.write(temporary, JSON.generate(request))
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # `ace-hitl close CASE` (task 8xs.t.pu0.6): terminal close, only
10
+ # after the proven applied effect. A duplicate close is a no-op;
11
+ # a late different answer was already rejected at delivery and a
12
+ # closed case never re-opens — an explicit decision change needs
13
+ # a NEW case.
14
+ class Close < Ace::Support::Cli::Command
15
+ include Ace::Support::Cli::Base
16
+
17
+ desc "Close a case terminally after its applied effect"
18
+
19
+ example ['ace-hitl close 8xhkep']
20
+
21
+ argument :case_id, required: true, desc: "Exact HITL case id"
22
+
23
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
24
+
25
+ def call(case_id:, **)
26
+ state = Ace::Hitl::Organisms::HitlManager.new.close_case(case_id)
27
+ puts JSON.generate({"case" => case_id, "state" => state.to_s})
28
+ rescue Ace::Hitl::Organisms::HitlManager::UnknownCaseError,
29
+ Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError,
30
+ Ace::Hitl::Organisms::HitlManager::LifecycleTransitionError => e
31
+ raise Ace::Support::Cli::Error.new("not closed: #{e.message}")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # `ace-hitl history` (task 8xs.t.pu0.c): the citable decision
10
+ # history for the NEXT Second Captain suggestion — answered,
11
+ # NON-SECRET question cases of THIS project scope, each row with
12
+ # its checkable references (case id, suggestion, decision,
13
+ # lesson). Secret/OTP cases never reach the model; no wider
14
+ # lab-wide sharing. No embedding store or decision index: HITL
15
+ # stays the single source.
16
+ class History < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+
19
+ desc "List the citable decision history (answered, non-secret cases of this scope)"
20
+
21
+ option :scope, type: :string, default: "current", desc: "current or all linked worktrees"
22
+ option :limit, type: :integer, default: 50, desc: "Maximum rows"
23
+ option :format, default: "json", desc: "Output format (json, table)"
24
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
25
+
26
+ def call(scope: "current", limit: 50, format: "json", quiet: false, **)
27
+ rows = Ace::Hitl::Organisms::HitlManager.new
28
+ .decision_history(scope: scope, limit: limit)
29
+ return if quiet
30
+
31
+ if format == "table"
32
+ puts render_table(rows)
33
+ else
34
+ puts JSON.pretty_generate({"history" => rows, "count" => rows.size})
35
+ end
36
+ rescue ArgumentError => e
37
+ raise Ace::Support::Cli::Error.new(e.message)
38
+ end
39
+
40
+ private
41
+
42
+ # One row per decided case: the case id, the decision (truncated
43
+ # to one line for the terminal) and the citable lesson relation.
44
+ def render_table(rows)
45
+ return "no decided cases in this scope" if rows.empty?
46
+
47
+ lines = ["CASE DECISION LESSON"]
48
+ rows.each do |row|
49
+ decision = row["decision"].to_s.gsub(/\s+/, " ")
50
+ decision = decision.length > 44 ? "#{decision[0, 43]}…" : decision
51
+ lesson = row["lesson_relation"].to_s.empty? ? "-" : row["lesson_relation"].to_s
52
+ lines << format("%-18s %-44s %s", row["id"], decision, lesson)
53
+ end
54
+ lines.join("\n")
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # `ace-hitl lesson CASE_ID --by AGENT --relation accepted|rejected|
10
+ # modified --takeaway ...` (task 8xs.t.pu0.c): the advisory
11
+ # agent's takeaway from comparing the accepted human decision
12
+ # with its own suggestion — one `## Lesson` section at the same
13
+ # case, referencing the exact question/suggestion/decision
14
+ # revisions. Never a new decision; never changes the answer's
15
+ # author; identical repeats are no-ops.
16
+ class Lesson < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+
19
+ desc "Write the lesson drawn from the human decision vs the suggestion"
20
+
21
+ example ['ace-hitl lesson 8xhkep --by second-captain --relation rejected ',
22
+ ' --takeaway "Captain prioritized rollout safety over speed"']
23
+
24
+ argument :case_id, required: true, desc: "Exact HITL case id"
25
+
26
+ option :by, type: :string, required: true, desc: "Advisory agent author identity"
27
+ option :relation, type: :string, required: true,
28
+ desc: "accepted | rejected | modified (decision vs suggestion)"
29
+ option :takeaway, type: :string, required: true, desc: "What the next suggestion should learn"
30
+ option :refs, type: :string, desc: "Comma-separated extra references"
31
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
32
+
33
+ def call(case_id:, by:, relation:, takeaway:, refs: nil, **)
34
+ state = Ace::Hitl::Organisms::HitlManager.new
35
+ .add_lesson(case_id, by: by, relation: relation, takeaway: takeaway,
36
+ refs: refs.to_s.split(",").map(&:strip).reject(&:empty?))
37
+ puts JSON.generate({"case" => case_id, "lesson" => state.to_s})
38
+ rescue Ace::Hitl::Organisms::HitlManager::UnknownCaseError,
39
+ Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError,
40
+ Ace::Hitl::Organisms::HitlManager::LessonRefusedError => e
41
+ raise Ace::Support::Cli::Error.new("lesson refused: #{e.message}")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # `ace-hitl reply CASE_ID --stdin` (task 8xs.t.pu0.5): the
10
+ # receiver-side half of HITL answer delivery. Hermes runs this
11
+ # command on the OWNER's machine, in the owner's directory, with
12
+ # a structured payload on stdin — the answer text never becomes
13
+ # a shell command or an argv element. The output names ONLY the
14
+ # case and its state: `{"case": ..., "state": "received"}` (a
15
+ # redelivered identical answer reports `"duplicate"`). Success
16
+ # means ace-hitl CONFIRMED the durable write — a reply frame
17
+ # sitting in a transport is not delivery.
18
+ #
19
+ # Every failure is an explicit non-delivery with no side effects
20
+ # on any other case: an empty answer, a report instead of a
21
+ # question, a conflicting answer, or a reference that is not the
22
+ # EXACT case id (no shortcut or latest resolution) all fail
23
+ # without writing.
24
+ class Reply < Ace::Support::Cli::Command
25
+ include Ace::Support::Cli::Base
26
+
27
+ desc "Receive a delivered human answer for one exact case; answer on stdin"
28
+
29
+ example [
30
+ 'echo "Proceed with batch 2" | ace-hitl reply 8xtab1cd --stdin'
31
+ ]
32
+
33
+ argument :case_id, required: true, desc: "Exact HITL case id from the ask"
34
+
35
+ option :stdin, type: :boolean, required: true, desc: "Read the answer text from stdin"
36
+
37
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
38
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
39
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
40
+
41
+ def call(case_id:, stdin: nil, **options)
42
+ unless stdin
43
+ raise Ace::Support::Cli::Error.new("reply reads the answer from stdin; pass --stdin")
44
+ end
45
+
46
+ answer = $stdin.read.to_s
47
+ if answer.strip.empty?
48
+ raise Ace::Support::Cli::Error.new("empty answer; nothing was written")
49
+ end
50
+
51
+ state = Ace::Hitl::Organisms::HitlManager.new.receive_answer(case_id, answer)
52
+ # Only the case and the outcome — never the answer text.
53
+ puts JSON.generate({"case" => case_id, "state" => state.to_s})
54
+ rescue Ace::Hitl::Organisms::HitlManager::UnknownCaseError,
55
+ Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError,
56
+ Ace::Hitl::Organisms::HitlManager::NotAnswerableError,
57
+ Ace::Hitl::Organisms::HitlManager::AnswerConflictError,
58
+ Ace::Hitl::Organisms::HitlManager::TerminalCaseError => e
59
+ raise Ace::Support::Cli::Error.new("not delivered: #{e.message}")
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # `ace-hitl suggest CASE_ID --by AUTHOR --text ... --rationale ...`
10
+ # (task 8xs.t.pu0.b): the Second Captain's ADVISORY proposal —
11
+ # one marked section in the exact case, with author, rationale
12
+ # and grounds. Never an Answer, never a human approval, never a
13
+ # gate release. Identical repeats are no-ops; a second different
14
+ # proposal, a secret case, or a case the human already answered
15
+ # are explicit refusals without side effects.
16
+ class Suggest < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+
19
+ desc "Write the Second Captain's marked advisory suggestion into one exact case"
20
+
21
+ example ['ace-hitl suggest 8xhkep --by second-captain --text "Proceed" ',
22
+ ' --rationale "tests green, review accept at same head" --refs pr:398']
23
+
24
+ argument :case_id, required: true, desc: "Exact HITL case id"
25
+
26
+ option :by, type: :string, required: true, desc: "Advisory author identity"
27
+ option :text, type: :string, required: true, desc: "Proposed answer text"
28
+ option :rationale, type: :string, required: true, desc: "Why this proposal is defensible"
29
+ option :refs, type: :string, desc: "Comma-separated grounds references"
30
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
31
+
32
+ def call(case_id:, by:, text:, rationale:, refs: nil, **)
33
+ state = Ace::Hitl::Organisms::HitlManager.new
34
+ .add_suggestion(case_id, author: by, text: text, rationale: rationale,
35
+ refs: refs.to_s.split(",").map(&:strip).reject(&:empty?))
36
+ puts JSON.generate({"case" => case_id, "suggestion" => state.to_s})
37
+ rescue Ace::Hitl::Organisms::HitlManager::UnknownCaseError,
38
+ Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError,
39
+ Ace::Hitl::Organisms::HitlManager::SuggestionRefusedError,
40
+ Ace::Hitl::Organisms::HitlManager::TerminalCaseError => e
41
+ raise Ace::Support::Cli::Error.new("suggestion refused: #{e.message}")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # `ace-hitl sweep [--apply]` (task 8xs.t.pu0.6): qualify transport
10
+ # leftovers against the CANONICAL case files, list-first. The
11
+ # only deletion candidates are ask-wake request files whose case
12
+ # is terminally closed or cancelled; active cases (pending /
13
+ # received / applied), unknown ids, unparseable requests and the
14
+ # canonical decision history are always preserved. Without
15
+ # --apply nothing is deleted.
16
+ class Sweep < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+
19
+ desc "List (and with --apply, remove) transport leftovers of terminally closed cases"
20
+
21
+ option :apply, type: :boolean, default: false,
22
+ desc: "Remove the qualified leftovers (default: list only)"
23
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
24
+
25
+ def call(apply: false, **)
26
+ report = Ace::Hitl::Organisms::HitlManager.new.sweep(apply: apply)
27
+ puts JSON.pretty_generate(report)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/ace/hitl/cli.rb CHANGED
@@ -4,6 +4,13 @@ require "ace/support/cli"
4
4
  require_relative "../hitl/version"
5
5
  require_relative "cli/commands/create"
6
6
  require_relative "cli/commands/ask"
7
+ require_relative "cli/commands/reply"
8
+ require_relative "cli/commands/apply"
9
+ require_relative "cli/commands/close"
10
+ require_relative "cli/commands/sweep"
11
+ require_relative "cli/commands/suggest"
12
+ require_relative "cli/commands/lesson"
13
+ require_relative "cli/commands/history"
7
14
  require_relative "cli/commands/send"
8
15
  require_relative "cli/commands/operator_question"
9
16
  require_relative "cli/commands/read"
@@ -43,7 +50,14 @@ module Ace
43
50
  ["cancel", "Cancel one HITL relay request with an audited reason"],
44
51
  ["pending", "List answerable HITL relay requests (transport)"],
45
52
  ["states", "List public HITL lifecycle projections (transport)"],
46
- ["duty", "Project pending and escalated HITL requests (transport)"]
53
+ ["duty", "Project pending and escalated HITL requests (transport)"],
54
+ ["reply", "Receive a delivered human answer for one exact case (stdin)"],
55
+ ["suggest", "Write the Second Captain's marked advisory suggestion into one exact case"],
56
+ ["lesson", "Write the lesson drawn from the human decision vs the suggestion"],
57
+ ["history", "List the citable decision history (answered, non-secret cases)"],
58
+ ["apply", "Record the answer's applied effect with its owner reference"],
59
+ ["close", "Close a case terminally after its applied effect"],
60
+ ["sweep", "List (and with --apply, remove) transport leftovers of terminal cases"]
47
61
  ].freeze
48
62
 
49
63
  HELP_EXAMPLES = [
@@ -67,6 +81,13 @@ module Ace
67
81
  register "observe", CLI::Commands::Observe
68
82
  register "update", CLI::Commands::Update
69
83
  register "ask", CLI::Commands::Ask
84
+ register "reply", CLI::Commands::Reply
85
+ register "apply", CLI::Commands::Apply
86
+ register "close", CLI::Commands::Close
87
+ register "sweep", CLI::Commands::Sweep
88
+ register "suggest", CLI::Commands::Suggest
89
+ register "lesson", CLI::Commands::Lesson
90
+ register "history", CLI::Commands::History
70
91
  register "send", CLI::Commands::Send
71
92
  register "wait", CLI::Commands::Wait
72
93
  register "deliver", CLI::Commands::Deliver