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,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ # Executes herdr CLI commands via argv arrays (ADR-031) and classifies
10
+ # failures into typed executor errors from herdr's machine-readable
11
+ # JSON error codes. The single seam between ace-herdr and the herdr
12
+ # binary; tests substitute this class.
13
+ class HerdrExecutor
14
+ DEFAULT_BINARY = "herdr"
15
+
16
+ def initialize(binary: DEFAULT_BINARY)
17
+ @binary = binary
18
+ end
19
+
20
+ # Probe the agent living in a pane (raises AgentNotFoundError when none)
21
+ def agent_get(pane)
22
+ run!([@binary, "agent", "get", pane])
23
+ end
24
+
25
+ # Start an agent in a pane at an interactive shell prompt.
26
+ # Success means the agent was detected and is ready for input.
27
+ def agent_start(name:, kind:, pane:, timeout_ms:)
28
+ run!([@binary, "agent", "start", name, "--kind", kind,
29
+ "--pane", pane, "--timeout", timeout_ms.to_s])
30
+ end
31
+
32
+ # Submit a prompt to the agent in a pane (push delivery). herdr
33
+ # rejects blocked agents pre-send with agent_blocked.
34
+ def agent_prompt(pane:, text:)
35
+ run!([@binary, "agent", "prompt", pane, text])
36
+ end
37
+
38
+ # Wait until the agent reaches one of the requested states
39
+ def agent_wait(pane:, until_states:, timeout_ms:)
40
+ cmd = [@binary, "agent", "wait", pane]
41
+ until_states.each { |state| cmd += ["--until", state] }
42
+ cmd += ["--timeout", timeout_ms.to_s]
43
+ run!(cmd)
44
+ end
45
+
46
+ # Run a shell command line in a pane (text + Enter in one call)
47
+ def pane_run(pane, command)
48
+ run!([@binary, "pane", "run", pane, command])
49
+ end
50
+
51
+ def pane_rename(pane, label)
52
+ run!([@binary, "pane", "rename", pane, label])
53
+ end
54
+
55
+ def pane_close(pane)
56
+ run!([@binary, "pane", "close", pane])
57
+ end
58
+
59
+ def pane_current
60
+ run!([@binary, "pane", "current", "--current"])
61
+ end
62
+
63
+ # Inspect a pane's processes (positive proof of process exit for tidy)
64
+ def pane_process_info(pane)
65
+ run!([@binary, "pane", "process-info", "--pane", pane])
66
+ end
67
+
68
+ # Create a tab in a workspace with a label and optional cwd
69
+ def tab_create(workspace_id:, label:, cwd: nil, focus: nil)
70
+ cmd = [@binary, "tab", "create", "--workspace", workspace_id, "--label", label]
71
+ cmd += ["--cwd", cwd] if cwd
72
+ cmd += ["--focus"] if focus
73
+ run!(cmd)
74
+ end
75
+
76
+ # --- terminal-control surface (spec 8wq.t.k84) -----------------------
77
+
78
+ # List workspaces (tmux sessions analogue)
79
+ def workspace_list
80
+ run!([@binary, "workspace", "list"])
81
+ end
82
+
83
+ # List tabs, optionally scoped to a workspace (tmux windows analogue)
84
+ def tab_list(workspace_id: nil)
85
+ cmd = [@binary, "tab", "list"]
86
+ cmd += ["--workspace", workspace_id] if workspace_id
87
+ run!(cmd)
88
+ end
89
+
90
+ # List panes, optionally scoped to a workspace
91
+ def pane_list(workspace_id: nil)
92
+ cmd = [@binary, "pane", "list"]
93
+ cmd += ["--workspace", workspace_id] if workspace_id
94
+ run!(cmd)
95
+ end
96
+
97
+ # Send literal text to a pane without submitting
98
+ def pane_send_text(pane, text)
99
+ run!([@binary, "pane", "send-text", pane, text])
100
+ end
101
+
102
+ # Send named keys to a pane, in order
103
+ def pane_send_keys(pane, keys)
104
+ run!([@binary, "pane", "send-keys", pane, *keys])
105
+ end
106
+
107
+ # Send named keys to the agent in a pane, in order
108
+ def agent_send_keys(pane, keys)
109
+ run!([@binary, "agent", "send-keys", pane, *keys])
110
+ end
111
+
112
+ # Read pane terminal output as raw text (capture). The raw runner
113
+ # keeps stdout verbatim — no trimming of leading/trailing blank lines
114
+ def pane_read(pane, source: "recent", lines: nil)
115
+ cmd = [@binary, "pane", "read", pane, "--source", source]
116
+ cmd += ["--lines", lines.to_s] if lines
117
+ run_raw(cmd)
118
+ end
119
+
120
+ # Wait for pane output containing a literal pattern. herdr checks
121
+ # existing content immediately, then polls; timeout fails closed.
122
+ def pane_wait_output(pane, pattern:, source: "recent", lines: nil, timeout_ms: nil)
123
+ cmd = [@binary, "pane", "wait-output", pane, "--match", pattern, "--source", source]
124
+ cmd += ["--lines", lines.to_s] if lines
125
+ cmd += ["--timeout", timeout_ms.to_s] if timeout_ms
126
+ run!(cmd)
127
+ rescue AgentNotReadyError => e
128
+ raise WaitTimeoutError, e.message
129
+ end
130
+
131
+ # Create a workspace with a label and optional cwd/focus
132
+ def workspace_create(label:, cwd: nil, focus: nil)
133
+ cmd = [@binary, "workspace", "create", "--label", label]
134
+ cmd += ["--cwd", cwd] if cwd
135
+ cmd += ["--focus"] if focus
136
+ run!(cmd)
137
+ end
138
+
139
+ # Close a tab (used to drop the native initial tab of a created
140
+ # workspace once the preset's declared tabs exist)
141
+ def tab_close(tab_id)
142
+ run!([@binary, "tab", "close", tab_id])
143
+ end
144
+
145
+ # Split a pane (direction: right|down); herdr reports the new pane
146
+ def pane_split(pane:, direction:, cwd: nil, ratio: nil, focus: nil)
147
+ cmd = [@binary, "pane", "split", "--pane", pane, "--direction", direction]
148
+ cmd += ["--cwd", cwd] if cwd
149
+ cmd += ["--ratio", ratio.to_s] if ratio
150
+ cmd += ["--focus"] if focus
151
+ run!(cmd)
152
+ end
153
+
154
+ def available?
155
+ run([@binary, "--version"]).success?
156
+ rescue ExecutorUnavailableError
157
+ false
158
+ end
159
+
160
+ private
161
+
162
+ def run!(cmd)
163
+ result = run(cmd)
164
+ raise classify(result, cmd) unless result.success?
165
+
166
+ result
167
+ end
168
+
169
+ # Like run!, but stdout is preserved verbatim (no strip) — for
170
+ # commands whose output is content (pane read/capture)
171
+ def run_raw(cmd)
172
+ result = run_raw_stdout(cmd)
173
+ raise classify(result, cmd) unless result.success?
174
+
175
+ result
176
+ end
177
+
178
+ def run(cmd)
179
+ stdout, stderr, status = Open3.capture3(*cmd)
180
+ ExecutionResult.new(
181
+ stdout: stdout.strip, stderr: stderr.strip,
182
+ success: status.success?, exit_code: status.exitstatus || -1
183
+ )
184
+ rescue Errno::ENOENT
185
+ raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
186
+ end
187
+
188
+ def run_raw_stdout(cmd)
189
+ stdout, stderr, status = Open3.capture3(*cmd)
190
+ ExecutionResult.new(
191
+ stdout: stdout, stderr: stderr.strip,
192
+ success: status.success?, exit_code: status.exitstatus || -1
193
+ )
194
+ rescue Errno::ENOENT
195
+ raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
196
+ end
197
+
198
+ # Map a failed result to a typed error from herdr's error codes
199
+ # (JSON: {"error":{"code":...,"message":...}}). The native machine
200
+ # code is kept in the message so CLI failures carry it.
201
+ def classify(result, cmd)
202
+ code, message = error_code(result)
203
+ case code
204
+ when "agent_blocked" then AgentBlockedError.new(tag_code(code, message))
205
+ when "agent_prompt_stalled" then AgentNotReadyError.new(tag_code(code, message))
206
+ when "pane_not_found" then PaneNotFoundError.new(tag_code(code, message))
207
+ when "agent_not_found" then AgentNotFoundError.new(tag_code(code, message))
208
+ when "tab_not_found" then TabNotFoundError.new(tag_code(code, message))
209
+ when "workspace_not_found" then WorkspaceNotFoundError.new(tag_code(code, message))
210
+ when "timeout" then AgentNotReadyError.new(tag_code(code, message))
211
+ else
212
+ CommandError.new(
213
+ "herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} " \
214
+ "#{message || result.stderr}".strip
215
+ )
216
+ end
217
+ end
218
+
219
+ def tag_code(code, message)
220
+ "#{code}: #{message}"
221
+ end
222
+
223
+ def error_code(result)
224
+ [result.parsed_json, parse_json(result.stderr)].each do |json|
225
+ next unless json.is_a?(Hash)
226
+
227
+ error = json["error"]
228
+ return [error["code"], error["message"]] if error.is_a?(Hash) && error["code"]
229
+ end
230
+ [nil, nil]
231
+ end
232
+
233
+ def parse_json(text)
234
+ JSON.parse(text)
235
+ rescue JSON::ParserError
236
+ nil
237
+ end
238
+ end
239
+
240
+ # Immutable result of a herdr command execution
241
+ class ExecutionResult
242
+ attr_reader :stdout, :stderr, :exit_code
243
+
244
+ def initialize(stdout:, stderr:, success:, exit_code:)
245
+ @stdout = stdout
246
+ @stderr = stderr
247
+ @success = success
248
+ @exit_code = exit_code
249
+ end
250
+
251
+ def success?
252
+ @success
253
+ end
254
+
255
+ # Parsed stdout JSON or nil
256
+ def parsed_json
257
+ JSON.parse(@stdout)
258
+ rescue JSON::ParserError
259
+ nil
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Herdr
5
+ module Molecules
6
+ # Positive-evidence probe for tidy (spec 8wq.t.1w0): the only component
7
+ # that interprets native `agent get` and `pane process-info` responses.
8
+ # A pane qualifies for closing solely on explicit completion proof — an
9
+ # observed `done` agent state or a pane with no foreground process.
10
+ # Every unrecognized response, missing field, or probe failure is
11
+ # preserve-only (no proof != dead).
12
+ module PaneTidyProbe
13
+ module_function
14
+
15
+ # Probe the agent in a pane and, when no agent exists, the pane's
16
+ # processes. Returns:
17
+ # :agent_done - agent observed in state done (eligible)
18
+ # :process_exited - pane alive, empty foreground process list (eligible)
19
+ # :active - live agent (idle/working/blocked) or live process
20
+ # :unknown - unreadable/absent evidence (preserve)
21
+ # :unreadable - probe failure (preserve)
22
+ # :gone - pane no longer exists (nothing to close)
23
+ def pane_evidence(executor, pane)
24
+ agent_evidence = agent_evidence(executor, pane)
25
+ return agent_evidence unless agent_evidence == :no_agent
26
+
27
+ process_evidence(executor, pane)
28
+ end
29
+
30
+ # Interpret `agent get`: result.agent.agent_status. A missing agent
31
+ # (:no_agent) defers to the process probe.
32
+ def agent_evidence(executor, pane)
33
+ status = dug_value(executor.agent_get(pane).parsed_json, "result", "agent", "agent_status")
34
+ case status
35
+ when "done" then :agent_done
36
+ when "idle", "working", "blocked" then :active
37
+ else :unknown
38
+ end
39
+ rescue AgentNotFoundError
40
+ :no_agent
41
+ rescue PaneNotFoundError
42
+ :gone
43
+ rescue ExecutorError
44
+ :unreadable
45
+ end
46
+
47
+ # Interpret `pane process-info`. herdr serializes
48
+ # `foreground_processes` with serde skip_serializing_if (v0.9.1
49
+ # schema/panes.rs), so within a well-formed process_info object an
50
+ # OMITTED key is the process-exit proof; an explicit null or any
51
+ # non-array value is malformed evidence and preserves. Anything
52
+ # alive appears as a non-empty array.
53
+ def process_evidence(executor, pane)
54
+ info = dug_value(executor.pane_process_info(pane).parsed_json, "result", "process_info")
55
+ return :unknown unless info.is_a?(Hash)
56
+
57
+ processes = info["foreground_processes"]
58
+ return :unknown if info.key?("foreground_processes") && !processes.is_a?(Array)
59
+
60
+ processes.to_a.empty? ? :process_exited : :active
61
+ rescue PaneNotFoundError
62
+ :gone
63
+ rescue ExecutorError
64
+ :unreadable
65
+ end
66
+
67
+ # Path walk that fails closed: nil unless every hop is a Hash
68
+ def dug_value(parsed, *path)
69
+ current = parsed
70
+ path.each do |key|
71
+ return nil unless current.is_a?(Hash)
72
+
73
+ current = current[key]
74
+ end
75
+ current
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/config"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ # Finds and loads herdr workspace/tab YAML presets across the ADR-022
10
+ # config cascade:
11
+ # 1. project .ace/herdr/ (highest priority)
12
+ # 2. user ~/.ace/herdr/
13
+ # 3. gem .ace-defaults/herdr/ (lowest priority)
14
+ class PresetLoader
15
+ PRESET_TYPES = %w[workspaces tabs].freeze
16
+
17
+ # @param gem_root [String] Gem root directory for defaults
18
+ # @param start_path [String, nil] Starting path for cascade traversal
19
+ def initialize(gem_root: Ace::Herdr.gem_root, start_path: nil)
20
+ @resolver = Ace::Support::Config.virtual_resolver(
21
+ config_dir: ".ace",
22
+ defaults_dir: ".ace-defaults",
23
+ start_path: start_path,
24
+ gem_path: gem_root
25
+ )
26
+ end
27
+
28
+ # Load a preset by type and name
29
+ #
30
+ # @param type [String] Preset type: "workspaces" or "tabs"
31
+ # @param name [String] Preset name (without .yml extension)
32
+ # @return [Hash, nil] Parsed YAML hash, or nil when unknown
33
+ def load(type, name)
34
+ absolute_path = @resolver.resolve_path("herdr/#{type}/#{name}.yml")
35
+ return nil unless absolute_path && File.exist?(absolute_path)
36
+
37
+ YAML.safe_load_file(absolute_path, permitted_classes: [Date], aliases: true) || {}
38
+ end
39
+
40
+ # List available preset names for a type, merged across the cascade
41
+ #
42
+ # @param type [String] Preset type: "workspaces" or "tabs"
43
+ # @return [Array<String>] Preset names (without .yml extension)
44
+ def list(type)
45
+ @resolver.glob("herdr/#{type}/*.yml").keys
46
+ .map { |relative_path| File.basename(relative_path, ".yml") }
47
+ .sort.uniq
48
+ end
49
+
50
+ # List all preset types and their presets
51
+ #
52
+ # @return [Hash<String, Array<String>>] Map of type => preset names
53
+ def list_all
54
+ PRESET_TYPES.each_with_object({}) do |type, result|
55
+ presets = list(type)
56
+ result[type] = presets unless presets.empty?
57
+ end
58
+ end
59
+
60
+ # Create a lookup proc for PresetResolver
61
+ #
62
+ # @param type [String] Preset type to look up
63
+ # @return [Proc] Proc that takes a name and returns a preset hash
64
+ def to_lookup(type)
65
+ ->(name) { load(type, name) }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/config"
4
+
5
+ module Ace
6
+ module Herdr
7
+ module Molecules
8
+ # Resolves `preset:` references in herdr workspace/tab presets
9
+ # (spec 8wq.t.k84): recursive lookup with deep merge (overlay wins),
10
+ # array concat so collections compose, cycle detection, and
11
+ # fail-closed missing references.
12
+ module PresetResolver
13
+ MAX_DEPTH = 10
14
+
15
+ # Raised when a preset references an unknown nested preset
16
+ class PresetNotFoundError < Ace::Herdr::Error; end
17
+
18
+ # Raised when preset references form a cycle
19
+ class CircularPresetError < Ace::Herdr::Error; end
20
+
21
+ module_function
22
+
23
+ # Resolve a workspace hash: the root may inherit another workspace
24
+ # preset; each tab entry may inherit a tab preset.
25
+ #
26
+ # @param hash [Hash] Workspace configuration hash
27
+ # @param workspace_lookup [Proc] name => workspace preset hash
28
+ # @param tab_lookup [Proc] name => tab preset hash
29
+ # @return [Hash] Fully resolved workspace hash
30
+ def resolve_workspace(hash, workspace_lookup:, tab_lookup:)
31
+ resolved = resolve_refs(hash, lookup: workspace_lookup)
32
+ tabs = resolved["tabs"]
33
+ if tabs.is_a?(Array)
34
+ resolved = resolved.merge(
35
+ "tabs" => tabs.map { |entry| resolve_tab(tab_entry(entry), tab_lookup: tab_lookup) }
36
+ )
37
+ end
38
+ resolved
39
+ end
40
+
41
+ # Resolve a tab hash: it may inherit another tab preset; pane string
42
+ # shorthand becomes a command entry.
43
+ #
44
+ # @param hash [Hash, String] Tab configuration hash (or label shorthand)
45
+ # @param tab_lookup [Proc] name => tab preset hash
46
+ # @return [Hash] Resolved tab hash
47
+ def resolve_tab(hash, tab_lookup:)
48
+ resolved = resolve_refs(tab_entry(hash), lookup: tab_lookup)
49
+ panes = resolved["panes"]
50
+ if panes.is_a?(Array)
51
+ resolved = resolved.merge("panes" => panes.map { |entry| pane_entry(entry) })
52
+ end
53
+ resolved
54
+ end
55
+
56
+ # Resolve one `preset:` reference chain onto `hash`
57
+ #
58
+ # @param hash [Hash] Hash that may contain a "preset" key
59
+ # @param lookup [Proc] name => preset hash (nil when unknown)
60
+ # @param depth [Integer] Recursion depth (cycle guard)
61
+ # @return [Hash] Resolved hash with the base deep-merged underneath
62
+ def resolve_refs(hash, lookup:, depth: 0)
63
+ raise CircularPresetError, "Preset resolution exceeded max depth (#{MAX_DEPTH})" if depth >= MAX_DEPTH
64
+ return hash unless hash.is_a?(Hash) && hash.key?("preset")
65
+
66
+ name = hash["preset"].to_s
67
+ base = lookup.call(name)
68
+ raise PresetNotFoundError, "Unknown preset '#{name}'" if base.nil?
69
+
70
+ base = resolve_refs(base, lookup: lookup, depth: depth + 1)
71
+ overlay = hash.reject { |key, _| key == "preset" }
72
+ Ace::Support::Config::Atoms::DeepMerger.merge(base, overlay, array_strategy: :concat)
73
+ end
74
+
75
+ # @api private
76
+ def tab_entry(entry)
77
+ entry.is_a?(Hash) ? entry : {"label" => entry.to_s}
78
+ end
79
+
80
+ # @api private
81
+ # Pane string shorthand declares a command to run in a new split
82
+ def pane_entry(entry)
83
+ entry.is_a?(Hash) ? entry : {"command" => entry.to_s}
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end