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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "support"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module CLI
10
+ module Commands
11
+ class Deliver < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+ include Runtime
14
+
15
+ desc <<~DESC.strip
16
+ Push an answer to an agent pane (ace-hitl delivery contract)
17
+
18
+ Delivers are idempotent per event id and never lose the answer:
19
+ a write-ahead record survives crashes, missing agents are
20
+ bootstrapped, and transient failures retry with fixed backoff.
21
+ DESC
22
+
23
+ example [
24
+ "--session ws-1 --pane p5 --event-id evt-1 --answer-file answer.md",
25
+ "echo 'the answer' | ace-herdr deliver --pane p5",
26
+ "--session ws-1 --pane p5 --kind codex --label 8wm.t.vs0 (bootstraps if missing)"
27
+ ]
28
+
29
+ option :session, type: :string, desc: "Ref session (default: HERDR_SESSION)"
30
+ option :pane, type: :string, desc: "Ref pane (default: HERDR_PANE)"
31
+ option :event_id, type: :string, desc: "Idempotency key (derived from ref and content when omitted)"
32
+ option :kind, type: :string, desc: "Agent kind used when bootstrapping (default: config default_agent_kind)"
33
+ option :label, type: :string, desc: "Agent name used when bootstrapping (default: event id)"
34
+ option :answer_file, type: :string, desc: "Answer file (default: stdin)"
35
+ option :resume, type: :string, desc: "Event id to re-deliver from the stored record (crash recovery)"
36
+
37
+ def initialize(executor: nil)
38
+ @executor = executor
39
+ end
40
+
41
+ def call(**options)
42
+ translate_errors do
43
+ deliverer = Organisms::Deliverer.from_config(
44
+ executor: executor, deliveries_dir: deliveries_dir, config: config
45
+ )
46
+ if options[:resume]
47
+ result = deliverer.resume(options[:resume], kind: options[:kind], label: options[:label])
48
+ puts JSON.generate(ref: result.ref.to_h, state: result.state.to_s, resumed: true)
49
+ cli_error("delivery did not complete (state: #{result.state})") unless result.state == :delivered
50
+ return
51
+ end
52
+
53
+ ref = resolve_ref(options[:session], options[:pane])
54
+ answer = read_content(options[:answer_file], what: "answer")
55
+ result = deliverer.deliver(
56
+ ref, answer,
57
+ event_id: options[:event_id], kind: options[:kind], label: options[:label]
58
+ )
59
+ puts JSON.generate(ref: result.ref.to_h, state: result.state.to_s)
60
+ cli_error("delivery did not complete (state: #{result.state})") unless result.state == :delivered
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "support"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module CLI
10
+ module Commands
11
+ class Dispatch < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+ include Runtime
14
+
15
+ desc <<~DESC.strip
16
+ Start an agent in one command: tab + agent start + prompt
17
+
18
+ Defaults are deterministic: the caller's workspace, the label as
19
+ agent and pane name, the prompt from a file or stdin. Zero-token.
20
+ DESC
21
+
22
+ example [
23
+ "--label 8wm.t.vs0 --kind pi --prompt-file prompt.md",
24
+ "--label review --pane p7 --cwd /Users/me/project",
25
+ "--label 8wm.t.vs0 --no-prompt"
26
+ ]
27
+
28
+ option :label, type: :string, desc: "Agent and pane label (typically the task id)"
29
+ option :kind, type: :string, desc: "Agent kind (default: config default_agent_kind)"
30
+ option :workspace, type: :string, desc: "Target workspace (default: caller's workspace)"
31
+ option :pane, type: :string, desc: "Existing pane (default: create a new tab)"
32
+ option :cwd, type: :string, desc: "Working directory for a created tab"
33
+ option :prompt_file, type: :string, desc: "Prompt file (default: stdin)"
34
+ option :no_prompt, type: :boolean, desc: "Start the agent without submitting a prompt"
35
+
36
+ def initialize(executor: nil)
37
+ @executor = executor
38
+ end
39
+
40
+ def call(**options)
41
+ translate_errors do
42
+ cli_error("--label is required") if options[:label].to_s.empty?
43
+ prompt =
44
+ if options[:no_prompt]
45
+ ""
46
+ else
47
+ read_content(options[:prompt_file], what: "prompt")
48
+ end
49
+ dispatcher = Organisms::Dispatcher.from_config(executor: executor, config: config)
50
+ outcome = dispatcher.dispatch(
51
+ label: options.fetch(:label), prompt: prompt,
52
+ kind: options[:kind], workspace_id: options[:workspace],
53
+ pane: options[:pane], cwd: options[:cwd]
54
+ )
55
+ puts JSON.generate(outcome.to_h)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class List < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ List live herdr panes, tabs, or workspaces as one JSON line
18
+
19
+ Panes are the default scope; --workspace scopes panes and tabs.
20
+ DESC
21
+
22
+ example [
23
+ " # Panes across all workspaces",
24
+ "--workspace w1 # Panes in one workspace",
25
+ "--tabs --workspace w1 # Tabs in one workspace",
26
+ "--workspaces # Workspaces"
27
+ ]
28
+
29
+ option :panes, type: :boolean, desc: "List panes (default)"
30
+ option :tabs, type: :boolean, desc: "List tabs"
31
+ option :workspaces, type: :boolean, desc: "List workspaces"
32
+ option :workspace, type: :string, desc: "Scope panes/tabs to a workspace id"
33
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
34
+
35
+ def initialize(executor: nil)
36
+ @executor = executor
37
+ end
38
+
39
+ def call(**options)
40
+ translate_errors do
41
+ scope = selected_scope(options)
42
+ cli_error("Use only one of --panes, --tabs, or --workspaces") if scope == :ambiguous
43
+ cli_error("--workspaces does not accept --workspace") if scope == :workspaces && options[:workspace]
44
+
45
+ control = Organisms::ControlSurface.new(executor: executor)
46
+ payload =
47
+ case scope
48
+ when :workspaces
49
+ {workspaces: control.list_workspaces}
50
+ when :tabs
51
+ {tabs: control.list_tabs(workspace_id: options[:workspace])}
52
+ else
53
+ {panes: control.list_panes(workspace_id: options[:workspace])}
54
+ end
55
+ puts JSON.generate(payload) unless options[:quiet]
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def selected_scope(options)
62
+ scopes = %i[panes tabs workspaces].select { |key| options[key] }
63
+ return :ambiguous if scopes.length > 1
64
+
65
+ scopes.first || :panes
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class ListPresets < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ List available workspace/tab presets as one JSON line
18
+
19
+ The inventory reflects the merged ADR-022 cascade: gem defaults
20
+ under .ace-defaults/herdr/, overridden by ~/.ace/herdr/ and the
21
+ project .ace/herdr/.
22
+ DESC
23
+
24
+ example [
25
+ "",
26
+ "workspaces",
27
+ "tabs"
28
+ ]
29
+
30
+ argument :type, required: false, desc: "Scope: workspaces or tabs (default: both)"
31
+
32
+ def initialize(executor: nil, control: nil)
33
+ @executor = executor
34
+ @control = control
35
+ end
36
+
37
+ def call(type: nil, **options)
38
+ translate_errors do
39
+ control = @control || Organisms::ControlSurface.new(executor: executor)
40
+ puts JSON.generate(control.list_presets(type: type))
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class Send < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Send a command, raw text, or named keys to a pane
18
+
19
+ Input reaches the pane in declaration order. Panes hosting a
20
+ live agent get prompt semantics: text becomes one self-submitting
21
+ agent prompt (agent_blocked is rejected pre-send) and a single
22
+ trailing --key Enter is dropped and reported.
23
+ DESC
24
+
25
+ example [
26
+ "--pane p5 --cmd 'bundle exec rake test'",
27
+ "--pane p5 --msg 'continue with option 2' --key Enter",
28
+ "--pane p5 --key Esc --cmd 'reset'",
29
+ "--pane p5 --key Enter",
30
+ "--pane p5 --cmd 'y' --quiet"
31
+ ]
32
+
33
+ option :pane, type: :string, desc: "Target pane id"
34
+ option :cmd, type: :string, aliases: %w[-c], desc: "Command text to submit (at most one, before any --key)"
35
+ option :msg, type: :string, aliases: %w[-m], repeat: true, desc: "Literal text to type without submitting"
36
+ option :key, type: :string, aliases: %w[-k], repeat: true, desc: "Named key to send (for example enter, esc, ctrl+c)"
37
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
38
+
39
+ def initialize(executor: nil, argv_source: nil)
40
+ @executor = executor
41
+ @argv_source = argv_source
42
+ end
43
+
44
+ def call(**options)
45
+ translate_errors do
46
+ cli_error("--pane is required") if options[:pane].to_s.empty?
47
+ control = Organisms::ControlSurface.new(executor: executor)
48
+ result = control.send_input(pane: options.fetch(:pane), tokens: input_tokens(options))
49
+ puts JSON.generate(result) unless options[:quiet]
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # Declaration order carries meaning (spec 8wq.t.k84), so the argv
56
+ # token stream is the source of truth for real invocations. Parsed
57
+ # option arrays only back programmatic calls with no input flags on
58
+ # the command line (canonical order: cmd, msgs, keys).
59
+ def input_tokens(options)
60
+ argv_tokens(@argv_source || ARGV) || option_tokens(options)
61
+ end
62
+
63
+ def argv_tokens(argv)
64
+ tokens = []
65
+ seen_input_flag = false
66
+ index = 0
67
+ while index < argv.length
68
+ token = argv[index]
69
+ if (match = token.match(/\A(--cmd|-c|--msg|-m|--key|-k)=(.*)\z/m))
70
+ seen_input_flag = true
71
+ tokens << {type: flag_type(match[1]), value: match[2]}
72
+ index += 1
73
+ elsif (flag = %w[--cmd -c --msg -m --key -k].include?(token) && token)
74
+ seen_input_flag = true
75
+ tokens << {type: flag_type(flag), value: argv[index + 1]}
76
+ index += 2
77
+ else
78
+ index += 1
79
+ end
80
+ end
81
+ seen_input_flag ? tokens : nil
82
+ end
83
+
84
+ def flag_type(flag)
85
+ case flag
86
+ when "--cmd", "-c" then :cmd
87
+ when "--msg", "-m" then :msg
88
+ else :key
89
+ end
90
+ end
91
+
92
+ def option_tokens(options)
93
+ tokens = []
94
+ tokens << {type: :cmd, value: options[:cmd]} if options[:cmd]
95
+ Array(options[:msg]).each { |value| tokens << {type: :msg, value: value} }
96
+ Array(options[:key]).each { |value| tokens << {type: :key, value: value} }
97
+ tokens
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module CLI
10
+ module Commands
11
+ # Shared collaborator construction for ace-herdr commands. The
12
+ # executor is injectable so tests can substitute a fake; commands
13
+ # registered in the CLI use the real herdr binary.
14
+ module Runtime
15
+ def executor
16
+ @executor ||= Ace::Herdr::Molecules::HerdrExecutor.new
17
+ end
18
+
19
+ def config
20
+ @config ||= Ace::Herdr.config
21
+ end
22
+
23
+ def deliveries_dir
24
+ File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", Dir.pwd)
25
+ end
26
+
27
+ # Reverse address from flags or the caller's herdr environment,
28
+ # fail closed (spec 8wm.t.vrz §3)
29
+ def resolve_ref(session, pane)
30
+ Ace::Hitl::Providers::Ref.new(
31
+ session: Ace::Hitl::Providers::Ref.validate!(
32
+ session || ENV["HERDR_SESSION"], "HERDR_SESSION"
33
+ ),
34
+ pane: Ace::Hitl::Providers::Ref.validate!(
35
+ pane || ENV["HERDR_PANE"], "HERDR_PANE"
36
+ )
37
+ )
38
+ end
39
+
40
+ # Answer/prompt content from a file or stdin
41
+ def read_content(path, what:)
42
+ return $stdin.read if path.nil? || path.empty?
43
+
44
+ File.read(path)
45
+ rescue Errno::ENOENT
46
+ raise Ace::Support::Cli::Error, "#{what} file not found: #{path}"
47
+ end
48
+
49
+ def cli_error(message)
50
+ raise Ace::Support::Cli::Error, message
51
+ end
52
+
53
+ # Contract and executor failures become CLI errors (ADR-023)
54
+ def translate_errors
55
+ yield
56
+ rescue Ace::Hitl::Providers::InvalidRefError,
57
+ Ace::Hitl::Providers::ProviderUnavailableError,
58
+ Ace::Herdr::ValidationError,
59
+ Ace::Herdr::TargetResolutionError,
60
+ Ace::Herdr::WaitTimeoutError,
61
+ Ace::Herdr::ExecutorError => e
62
+ raise Ace::Support::Cli::Error, e.message
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class Tab < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Create a tab from a preset (≈ ace-tmux window)
18
+
19
+ The tab lands in the caller's workspace unless --workspace is
20
+ given. Presets resolve through the ADR-022 cascade and may
21
+ compose via `preset:` refs; --cwd overrides the resolved tab cwd.
22
+ DESC
23
+
24
+ example [
25
+ "agent",
26
+ "agent --workspace w1",
27
+ "agent --cwd /path/to/project"
28
+ ]
29
+
30
+ argument :preset, desc: "Tab preset name (list with ace-herdr --list-presets tabs)"
31
+ option :workspace, type: :string, desc: "Target workspace (default: caller's workspace)"
32
+ option :cwd, type: :string, desc: "Working directory overriding the resolved tab/pane cwd"
33
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
34
+
35
+ def initialize(executor: nil, control: nil)
36
+ @executor = executor
37
+ @control = control
38
+ end
39
+
40
+ def call(preset: nil, **options)
41
+ translate_errors do
42
+ cli_error("preset name is required") if preset.to_s.empty?
43
+ control = @control || Organisms::ControlSurface.from_config(executor: executor, config: config)
44
+ result = control.create_tab(
45
+ preset, workspace_id: options[:workspace], cwd: options[:cwd]
46
+ )
47
+ puts JSON.generate(result) unless options[:quiet]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class Tidy < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Report cleanable agent panes and delivery records as one JSON line
18
+
19
+ Dry run by default: nothing is closed or removed. With --apply,
20
+ panes close only on positive completion evidence (observed done
21
+ state or dead pane process) re-confirmed right before closing;
22
+ delivered records older than tidy.delivered_retention_days are
23
+ archived. Unknown, unreadable, pending, retryable, and failed
24
+ resources are never touched.
25
+ DESC
26
+
27
+ example [
28
+ " # Dry run: report candidates only",
29
+ "--apply # Close eligible panes, archive old delivered records",
30
+ "--quiet # Suppress the report"
31
+ ]
32
+
33
+ option :apply, type: :boolean, desc: "Close eligible panes and archive old delivered records (default: dry run)"
34
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
35
+
36
+ def initialize(executor: nil, config: nil)
37
+ @executor = executor
38
+ @injected_config = config
39
+ end
40
+
41
+ def call(**options)
42
+ translate_errors do
43
+ report = tidy.run(apply: options[:apply] || false)
44
+ puts JSON.generate(report) unless options[:quiet]
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def config
51
+ @injected_config || super
52
+ end
53
+
54
+ def tidy
55
+ Organisms::Tidy.new(
56
+ executor: executor,
57
+ deliveries_dir: deliveries_dir,
58
+ retention_days: (config["tidy"] || {})["delivered_retention_days"]
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class Wait < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Wait for an agent state or matching pane output
18
+
19
+ --for agent (default) waits for an agent state without scraping
20
+ pane output. --for output waits until the pane content contains
21
+ the literal --pattern; existing content is checked immediately,
22
+ then polled.
23
+ DESC
24
+
25
+ example [
26
+ "--pane p5",
27
+ "--pane p5 --until done --timeout 120",
28
+ "--pane p5 --for output --pattern 'build succeeded'",
29
+ "--pane p5 --for output --pattern done --timeout 30"
30
+ ]
31
+
32
+ option :pane, type: :string, desc: "Target pane id"
33
+ option :for, type: :string, desc: "What to wait for: agent (default) or output"
34
+ option :until, type: :string, desc: "Agent states, comma-separated: idle,working,blocked,done,unknown (default: idle,done,blocked)"
35
+ option :pattern, type: :string, desc: "Literal output pattern to wait for (with --for output)"
36
+ option :timeout, type: :integer, desc: "Timeout in seconds (default: config timeouts.wait)"
37
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
38
+
39
+ def initialize(executor: nil)
40
+ @executor = executor
41
+ end
42
+
43
+ def call(**options)
44
+ translate_errors do
45
+ cli_error("--pane is required") if options[:pane].to_s.empty?
46
+ mode = options[:for] || "agent"
47
+ unless %w[agent output].include?(mode)
48
+ cli_error("--for must be agent or output")
49
+ end
50
+ timeout_seconds =
51
+ options[:timeout] || (config.dig("timeouts", "wait") || 30)
52
+
53
+ if mode == "output"
54
+ wait_for_output(options, timeout_seconds)
55
+ else
56
+ wait_for_agent(options, timeout_seconds)
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def wait_for_agent(options, timeout_seconds)
64
+ cli_error("--pattern requires --for output") if options[:pattern]
65
+ until_states = (options[:until] || "idle,done,blocked")
66
+ .split(",").map(&:strip).reject(&:empty?)
67
+ executor.agent_wait(
68
+ pane: options.fetch(:pane), until_states: until_states,
69
+ timeout_ms: timeout_seconds * 1000
70
+ )
71
+ puts JSON.generate(pane: options.fetch(:pane), state: "ready") unless options[:quiet]
72
+ end
73
+
74
+ def wait_for_output(options, timeout_seconds)
75
+ cli_error("--for output requires --pattern") if options[:pattern].to_s.empty?
76
+ cli_error("--until requires --for agent") if options[:until]
77
+ control = Organisms::ControlSurface.new(executor: executor)
78
+ control.wait_output(
79
+ pane: options.fetch(:pane), pattern: options.fetch(:pattern),
80
+ timeout_ms: timeout_seconds * 1000
81
+ )
82
+ puts JSON.generate(pane: options.fetch(:pane), matched: true) unless options[:quiet]
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class Workspace < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Create a workspace from a preset (≈ ace-tmux start)
18
+
19
+ Presets resolve through the ADR-022 cascade (.ace/herdr/ >
20
+ ~/.ace/herdr/ > gem defaults) and may compose via `preset:` refs.
21
+ Tabs, panes, splits, commands, and agents are created in declared
22
+ order; --cwd overrides the resolved root/tab cwd.
23
+ DESC
24
+
25
+ example [
26
+ "development",
27
+ "development --cwd /path/to/project",
28
+ "development --quiet"
29
+ ]
30
+
31
+ argument :preset, desc: "Workspace preset name (list with ace-herdr --list-presets workspaces)"
32
+ option :cwd, type: :string, desc: "Working directory overriding the resolved root/tab cwd"
33
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
34
+
35
+ def initialize(executor: nil, control: nil)
36
+ @executor = executor
37
+ @control = control
38
+ end
39
+
40
+ def call(preset: nil, **options)
41
+ translate_errors do
42
+ cli_error("preset name is required") if preset.to_s.empty?
43
+ control = @control || Organisms::ControlSurface.from_config(executor: executor, config: config)
44
+ result = control.create_workspace(preset, cwd: options[:cwd])
45
+ puts JSON.generate(result) unless options[:quiet]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end