ace-herdr 0.1.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/herdr/config.yml +26 -0
  3. data/.ace-defaults/herdr/tabs/agent.yml +11 -0
  4. data/.ace-defaults/herdr/workspaces/development.yml +23 -0
  5. data/CHANGELOG.md +22 -0
  6. data/LICENSE +21 -0
  7. data/README.md +54 -0
  8. data/Rakefile +12 -0
  9. data/docs/usage.md +306 -0
  10. data/exe/ace-herdr +17 -0
  11. data/lib/ace/herdr/atoms/answer_digest.rb +18 -0
  12. data/lib/ace/herdr/cli/commands/capture.rb +55 -0
  13. data/lib/ace/herdr/cli/commands/close.rb +53 -0
  14. data/lib/ace/herdr/cli/commands/deliver.rb +67 -0
  15. data/lib/ace/herdr/cli/commands/dispatch.rb +62 -0
  16. data/lib/ace/herdr/cli/commands/list.rb +71 -0
  17. data/lib/ace/herdr/cli/commands/list_presets.rb +47 -0
  18. data/lib/ace/herdr/cli/commands/send.rb +103 -0
  19. data/lib/ace/herdr/cli/commands/support.rb +68 -0
  20. data/lib/ace/herdr/cli/commands/tab.rb +54 -0
  21. data/lib/ace/herdr/cli/commands/tidy.rb +65 -0
  22. data/lib/ace/herdr/cli/commands/wait.rb +88 -0
  23. data/lib/ace/herdr/cli/commands/workspace.rb +52 -0
  24. data/lib/ace/herdr/cli.rb +98 -0
  25. data/lib/ace/herdr/errors.rb +51 -0
  26. data/lib/ace/herdr/models/delivery_record.rb +109 -0
  27. data/lib/ace/herdr/models/dispatch_outcome.rb +29 -0
  28. data/lib/ace/herdr/molecules/delivery_record_store.rb +150 -0
  29. data/lib/ace/herdr/molecules/herdr_executor.rb +264 -0
  30. data/lib/ace/herdr/molecules/pane_tidy_probe.rb +80 -0
  31. data/lib/ace/herdr/molecules/preset_loader.rb +70 -0
  32. data/lib/ace/herdr/molecules/preset_resolver.rb +88 -0
  33. data/lib/ace/herdr/organisms/control_surface.rb +575 -0
  34. data/lib/ace/herdr/organisms/deliverer.rb +358 -0
  35. data/lib/ace/herdr/organisms/dispatcher.rb +130 -0
  36. data/lib/ace/herdr/organisms/tidy.rb +188 -0
  37. data/lib/ace/herdr/version.rb +7 -0
  38. data/lib/ace/herdr.rb +103 -0
  39. metadata +224 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../herdr"
6
+ require_relative "cli/commands/deliver"
7
+ require_relative "cli/commands/dispatch"
8
+ require_relative "cli/commands/wait"
9
+ require_relative "cli/commands/close"
10
+ require_relative "cli/commands/tidy"
11
+ require_relative "cli/commands/list"
12
+ require_relative "cli/commands/send"
13
+ require_relative "cli/commands/capture"
14
+ require_relative "cli/commands/workspace"
15
+ require_relative "cli/commands/tab"
16
+ require_relative "cli/commands/list_presets"
17
+
18
+ module Ace
19
+ module Herdr
20
+ # ace-support-cli based CLI registry for ace-herdr
21
+ module CLI
22
+ extend Ace::Support::Cli::RegistryDsl
23
+
24
+ PROGRAM_NAME = "ace-herdr"
25
+
26
+ # Application commands with descriptions (for help output)
27
+ REGISTERED_COMMANDS = [
28
+ ["deliver", "Push an answer to an agent pane (ace-hitl delivery contract)"],
29
+ ["dispatch", "Start an agent in one command: tab + agent + prompt"],
30
+ ["list", "List live panes, tabs, or workspaces as one JSON line"],
31
+ ["send", "Send a command, raw text, or named keys to a pane"],
32
+ ["capture", "Print recent pane output as raw text"],
33
+ ["wait", "Wait for an agent state or matching pane output"],
34
+ ["close", "Rename and/or close a finished agent pane"],
35
+ ["tidy", "Report cleanable panes/delivery records; close and archive with --apply"],
36
+ ["workspace", "Create a workspace from a preset"],
37
+ ["tab", "Create a tab from a preset"],
38
+ ["--list-presets", "List available workspace/tab presets"]
39
+ ].freeze
40
+
41
+ HELP_EXAMPLES = [
42
+ "ace-herdr deliver --session ws-1 --pane p5 --event-id evt-1 --answer-file answer.md",
43
+ "echo 'the answer' | ace-herdr deliver --pane p5",
44
+ "ace-herdr dispatch --label 8wm.t.vs0 --kind pi --prompt-file prompt.md",
45
+ "ace-herdr list --workspace w1",
46
+ "ace-herdr send --pane p5 --cmd 'bundle exec rake test'",
47
+ "ace-herdr send --pane p5 --msg 'continue' --key Enter",
48
+ "ace-herdr capture --pane p5 --lines 40",
49
+ "ace-herdr wait --pane p5 --for output --pattern done --timeout 30",
50
+ "ace-herdr workspace development",
51
+ "ace-herdr tab agent --workspace w1",
52
+ "ace-herdr --list-presets",
53
+ "ace-herdr close --pane p5 --rename done",
54
+ "ace-herdr tidy --apply"
55
+ ].freeze
56
+
57
+ # Start the CLI
58
+ #
59
+ # @param args [Array<String>] Command-line arguments
60
+ # @return [Integer] Exit code (0 for success, non-zero for failure)
61
+ def self.start(args)
62
+ Ace::Support::Cli::Runner.new(self).call(args: args)
63
+ end
64
+
65
+ # Register commands
66
+ register "deliver", CLI::Commands::Deliver.new
67
+ register "dispatch", CLI::Commands::Dispatch.new
68
+ register "list", CLI::Commands::List.new
69
+ register "send", CLI::Commands::Send.new
70
+ register "capture", CLI::Commands::Capture.new
71
+ register "wait", CLI::Commands::Wait.new
72
+ register "close", CLI::Commands::Close.new
73
+ register "tidy", CLI::Commands::Tidy.new
74
+ register "workspace", CLI::Commands::Workspace.new
75
+ register "tab", CLI::Commands::Tab.new
76
+ register "--list-presets", CLI::Commands::ListPresets.new
77
+
78
+ # Register version command
79
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
80
+ gem_name: "ace-herdr",
81
+ version: Ace::Herdr::VERSION
82
+ )
83
+ register "version", version_cmd
84
+ register "--version", version_cmd
85
+
86
+ # Register help command
87
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
88
+ program_name: PROGRAM_NAME,
89
+ version: Ace::Herdr::VERSION,
90
+ commands: REGISTERED_COMMANDS,
91
+ examples: HELP_EXAMPLES
92
+ )
93
+ register "help", help_cmd
94
+ register "--help", help_cmd
95
+ register "-h", help_cmd
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Herdr
5
+ # Base for herdr CLI executor failures; retry classification drives the
6
+ # ace-hitl DeliverResult state mapping (retryable vs terminal).
7
+ class ExecutorError < Error
8
+ # Terminal by default: retrying with identical content cannot succeed
9
+ def retryable?
10
+ false
11
+ end
12
+ end
13
+
14
+ # Agent rejected the prompt before any input was sent (agent_blocked)
15
+ class AgentBlockedError < ExecutorError; end
16
+
17
+ # Accepted prompt did not observe a working/blocked state in time
18
+ # (agent_prompt_stalled / wait timeout) — transient, safe to re-push
19
+ class AgentNotReadyError < ExecutorError
20
+ def retryable?
21
+ true
22
+ end
23
+ end
24
+
25
+ # Target pane does not exist (pane_not_found)
26
+ class PaneNotFoundError < ExecutorError; end
27
+
28
+ # Target tab does not exist (tab_not_found)
29
+ class TabNotFoundError < ExecutorError; end
30
+
31
+ # Target workspace does not exist (workspace_not_found)
32
+ class WorkspaceNotFoundError < ExecutorError; end
33
+
34
+ # No agent detected in the target pane
35
+ class AgentNotFoundError < ExecutorError; end
36
+
37
+ # herdr CLI binary or its socket is unreachable — transient
38
+ class ExecutorUnavailableError < ExecutorError
39
+ def retryable?
40
+ true
41
+ end
42
+ end
43
+
44
+ # Unclassified herdr CLI failure — treated as transient within retry limits
45
+ class CommandError < ExecutorError
46
+ def retryable?
47
+ true
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Herdr
7
+ module Models
8
+ # Write-ahead record of one push delivery. Immutable value object:
9
+ # state transitions return new instances; persistence lives in
10
+ # Molecules::DeliveryRecordStore.
11
+ #
12
+ # States: pending (written before first herdr contact), delivered,
13
+ # retryable (exhausted transient attempts; safe to re-push identical
14
+ # content), failed (terminal).
15
+ #
16
+ # The record carries the full answer so a crash after write-ahead but
17
+ # before the prompt never loses the content; it can be re-delivered
18
+ # via resume. Record files are 0600 (answers may be sensitive).
19
+ class DeliveryRecord
20
+ STATES = %w[pending delivered retryable failed].freeze
21
+
22
+ attr_reader :event_id, :session, :pane, :answer_digest, :answer,
23
+ :state, :attempts, :history, :created_at, :updated_at
24
+
25
+ def initialize(event_id:, session:, pane:, answer_digest:, answer: nil,
26
+ state: "pending", attempts: 0, history: [], created_at: nil, updated_at: nil)
27
+ raise ArgumentError, "unknown state: #{state}" unless STATES.include?(state)
28
+
29
+ @event_id = event_id
30
+ @session = session
31
+ @pane = pane
32
+ @answer_digest = answer_digest
33
+ @answer = answer
34
+ @state = state
35
+ @attempts = attempts
36
+ @history = history.dup.freeze
37
+ @created_at = created_at
38
+ @updated_at = updated_at
39
+ freeze
40
+ end
41
+
42
+ def self.from_h(hash)
43
+ new(
44
+ event_id: hash["event_id"], session: hash["session"], pane: hash["pane"],
45
+ answer_digest: hash["answer_digest"], answer: hash["answer"],
46
+ state: hash["state"] || "pending",
47
+ attempts: hash["attempts"] || 0, history: hash["history"] || [],
48
+ created_at: hash["created_at"], updated_at: hash["updated_at"]
49
+ )
50
+ end
51
+
52
+ def self.from_json(json)
53
+ from_h(JSON.parse(json))
54
+ end
55
+
56
+ def to_h
57
+ {
58
+ "event_id" => event_id, "session" => session, "pane" => pane,
59
+ "answer_digest" => answer_digest, "answer" => answer,
60
+ "state" => state,
61
+ "attempts" => attempts, "history" => history,
62
+ "created_at" => created_at, "updated_at" => updated_at
63
+ }
64
+ end
65
+
66
+ def delivered?
67
+ state == "delivered"
68
+ end
69
+
70
+ # A "submitting" history entry without a matching outcome means the
71
+ # previous run crashed between prompt submission and persistence;
72
+ # resending could duplicate the answer, so the outcome is ambiguous.
73
+ def ambiguous_submission?
74
+ last = history.last
75
+ last.is_a?(Hash) && last["action"] == "prompt" && last["outcome"] == "submitting"
76
+ end
77
+
78
+ # Return a copy with a history entry appended (no attempt counted);
79
+ # used for non-prompt events such as bootstrap actions
80
+ def append_event(detail:, timestamp:, state: nil)
81
+ self.class.new(
82
+ event_id: event_id, session: session, pane: pane,
83
+ answer_digest: answer_digest, answer: answer,
84
+ state: state || self.state,
85
+ attempts: attempts,
86
+ history: history + [detail.merge("at" => timestamp)],
87
+ created_at: created_at || timestamp, updated_at: timestamp
88
+ )
89
+ end
90
+
91
+ # Return a copy advanced to the next attempt with a history entry
92
+ # @param state [String] resulting state after the attempt
93
+ # @param detail [Hash] attempt detail (action, outcome, error)
94
+ # @param timestamp [String] RFC 3339 timestamp of the transition
95
+ def record_attempt(state:, detail:, timestamp:)
96
+ appended = append_event(state: state, detail: detail, timestamp: timestamp)
97
+ self.class.new(
98
+ event_id: event_id, session: session, pane: pane,
99
+ answer_digest: answer_digest, answer: answer,
100
+ state: state,
101
+ attempts: attempts + 1,
102
+ history: appended.history,
103
+ created_at: appended.created_at, updated_at: appended.updated_at
104
+ )
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Herdr
5
+ module Models
6
+ # Outcome of a one-command agent dispatch: pure data carrier
7
+ class DispatchOutcome
8
+ attr_reader :workspace_id, :pane, :agent_name, :kind, :tab_created, :prompted
9
+
10
+ def initialize(workspace_id:, pane:, agent_name:, kind:, tab_created:, prompted:)
11
+ @workspace_id = workspace_id
12
+ @pane = pane
13
+ @agent_name = agent_name
14
+ @kind = kind
15
+ @tab_created = tab_created
16
+ @prompted = prompted
17
+ freeze
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ "workspace" => workspace_id, "pane" => pane, "agent" => agent_name,
23
+ "kind" => kind, "tab_created" => tab_created, "prompted" => prompted
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ # Persists DeliveryRecord JSON files under a deliveries directory.
10
+ # Writes are atomic (temp file + rename, mode 0600) so a crash never
11
+ # yields a torn record and the answer stays re-deliverable. A per-event
12
+ # flock serializes concurrent deliveries of the same event.
13
+ module DeliveryRecordStore
14
+ module_function
15
+
16
+ # @return [Models::DeliveryRecord, nil] the record, falling back to
17
+ # the archive copy when the live record is gone — delivery
18
+ # idempotency (identical short-circuit, conflict fail-closed) must
19
+ # survive tidy archival
20
+ # @raise on malformed/unreadable content (fail closed: callers must
21
+ # not treat a corrupt record as absent)
22
+ def load(deliveries_dir, event_id)
23
+ path = resolve_record_path(deliveries_dir, event_id)
24
+ return nil unless path
25
+
26
+ Models::DeliveryRecord.from_json(File.read(path))
27
+ end
28
+
29
+ # Tidy's lock-guarded revalidation load: like load, but a malformed
30
+ # or unreadable record decodes as :unreadable instead of raising
31
+ # (reported as preserved, never archived on unprovable evidence)
32
+ # @return [Models::DeliveryRecord, nil, :unreadable]
33
+ def load_revalidated(deliveries_dir, event_id)
34
+ path = resolve_record_path(deliveries_dir, event_id)
35
+ return nil unless path
36
+
37
+ Models::DeliveryRecord.from_h(JSON.parse(File.read(path)))
38
+ rescue JSON::ParserError, ArgumentError, TypeError, NoMethodError, Errno::EACCES
39
+ :unreadable
40
+ end
41
+
42
+ # @return [String] path the record was written to
43
+ def save(record, deliveries_dir)
44
+ FileUtils.mkdir_p(deliveries_dir)
45
+ path = path_for(deliveries_dir, record.event_id)
46
+ tmp = "#{path}.tmp.#{Process.pid}"
47
+ File.open(tmp, IO::CREAT | IO::TRUNC | IO::WRONLY, 0o600) do |file|
48
+ file.write(JSON.generate(record.to_h))
49
+ end
50
+ File.rename(tmp, path)
51
+ path
52
+ end
53
+
54
+ # Hold the exclusive per-event lock while delivering; concurrent
55
+ # callers block until the winner finishes, then observe its record.
56
+ def with_lock(deliveries_dir, event_id)
57
+ FileUtils.mkdir_p(deliveries_dir)
58
+ File.open(lock_path(deliveries_dir, event_id), "a") do |lock|
59
+ lock.flock(File::LOCK_EX)
60
+ begin
61
+ return yield
62
+ ensure
63
+ lock.flock(File::LOCK_UN)
64
+ end
65
+ end
66
+ end
67
+
68
+ def path_for(deliveries_dir, event_id)
69
+ File.join(deliveries_dir, "#{event_id}.json")
70
+ end
71
+
72
+ def lock_path(deliveries_dir, event_id)
73
+ File.join(deliveries_dir, ".#{event_id}.lock")
74
+ end
75
+
76
+ # --- tidy (spec 8wq.t.1w0) -------------------------------------------
77
+
78
+ # Enumerate the top-level delivery records, sorted by event id.
79
+ # Lock files, temp files, and the archive directory are ignored;
80
+ # a malformed or unreadable file yields an entry with record: nil
81
+ # (reported as preserved, never removed).
82
+ # @return [Array<{event_id: String, record: Models::DeliveryRecord, nil}>]
83
+ def list_records(deliveries_dir)
84
+ return [] unless Dir.exist?(deliveries_dir)
85
+
86
+ Dir.children(deliveries_dir).sort.filter_map do |name|
87
+ next nil unless name.end_with?(".json")
88
+ next nil unless record_file?(File.join(deliveries_dir, name))
89
+
90
+ event_id = name.delete_suffix(".json")
91
+ record =
92
+ begin
93
+ read_record(File.join(deliveries_dir, name))
94
+ rescue Errno::EACCES
95
+ nil # unreadable: reported as preserved, never removed
96
+ rescue Errno::ENOENT
97
+ next nil # vanished between listing and read: nothing to preserve
98
+ end
99
+ {event_id: event_id, record: record}
100
+ end
101
+ end
102
+
103
+ # Archive directory for retired delivered records (inside the
104
+ # deliveries dir so a same-filesystem rename stays atomic)
105
+ def archive_dir(deliveries_dir)
106
+ File.join(deliveries_dir, "archive")
107
+ end
108
+
109
+ # Atomically move one record file into the archive directory
110
+ # (rename preserves the 0600 mode). Callers must hold the per-event
111
+ # lock and re-check eligibility under it. Idempotent: an already
112
+ # archived event just reports its archive path.
113
+ # @return [String] the archive path the record lives at
114
+ def archive(deliveries_dir, event_id)
115
+ dest = File.join(archive_dir(deliveries_dir), "#{event_id}.json")
116
+ return dest if !File.exist?(path_for(deliveries_dir, event_id)) && File.exist?(dest)
117
+
118
+ FileUtils.mkdir_p(archive_dir(deliveries_dir))
119
+ File.rename(path_for(deliveries_dir, event_id), dest)
120
+ dest
121
+ end
122
+
123
+ def record_file?(path)
124
+ File.file?(path) && !File.basename(path).start_with?(".")
125
+ end
126
+
127
+ # Live record path, falling back to the archive copy
128
+ def resolve_record_path(deliveries_dir, event_id)
129
+ live = path_for(deliveries_dir, event_id)
130
+ return live if File.exist?(live)
131
+
132
+ archived = File.join(archive_dir(deliveries_dir), "#{event_id}.json")
133
+ File.exist?(archived) ? archived : nil
134
+ end
135
+
136
+ # Decode a record file fail-closed: anything that is not a decodable
137
+ # delivery record (malformed JSON, non-object JSON, unknown state)
138
+ # reads as nil (preserved/unreadable), never raises
139
+ def read_record(path)
140
+ parsed = JSON.parse(File.read(path))
141
+ return nil unless parsed.is_a?(Hash)
142
+
143
+ Models::DeliveryRecord.from_h(parsed)
144
+ rescue JSON::ParserError, ArgumentError
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end